From dd66793b5ba00d5223f28af0122a03c663b7baca Mon Sep 17 00:00:00 2001 From: Mehdi Date: Wed, 19 Aug 2026 10:48:57 -0400 Subject: [PATCH] =?UTF-8?q?Release:=20leftover=20#359/#370/#372=20landing?= =?UTF-8?q?=20(dev=20=E2=86=92=20main)=20(#380)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(intelligence): add includeReportTimes to earnings endpoints (#111) + dependency refresh (#112) * feat(intelligence): add includeReportTimes to earnings endpoints (#111) FMP's earnings-calendar and earnings endpoints accept an includeReportTimes flag that returns the report time along with fiscal period and confirmation metadata. Neither endpoint exposed it. - Add include_report_times to get_earnings_calendar and get_historical_earnings on the sync and async clients - Extend EarningEvent with time, period_ending, fiscal_period, fiscal_year and confirmed; all default to None so existing calls are unaffected - Point HISTORICAL_EARNINGS at the stable /earnings path. The legacy historical/earning-calendar path 404s on /stable, so the method returned nothing; the cassette now records a 200 with real data - Accept limit on get_historical_earnings - Deprecate get_earnings_confirmed and get_earnings_surprises, both of which 404 on /stable, naming the include_report_times replacement Also refreshes all locked dependencies and GitHub Actions to their latest releases. MCP SDK 2.0 renamed FastMCP to MCPServer, so fmp_data.mcp._compat resolves whichever class the installed SDK provides and the mcp extra floor stays at >=1.28.1. Ruff 0.16 formats Python blocks inside Markdown, hence the docs churn; the black pre-commit hook is dropped since it now fights ruff. Reported by @joshuatz. Closes #107, #108, #109, #110. Co-Authored-By: Claude Opus 5 (1M context) * ci(nox): audit against current pip instead of suppressing its CVEs The security session inherited whatever pip virtualenv bundles (26.0.1), which carries PYSEC-2026-2875/2876 and made pip-audit fail on findings that belong to the scaffolding, not to project dependencies. Upgrading pip inside the session clears them and retires the CVE-2026-1703 suppression along with its TODO. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) * fix(intelligence): soft-fail dead earnings endpoints (release polish for #113) (#117) * fix(intelligence): soft-fail dead earnings endpoints and harden release polish Review follow-up for the #113 release branch: - get_earnings_confirmed / get_earnings_surprises warn and return [] without calling 404 FMP paths (match stock_news_sentiments) - Drop dead/deprecated intelligence tools from MCP DEFAULT_TOOLS - Soften EarningEvent / client / CHANGELOG wording on report-time fields - Document historical path fix and deprecations in public docs - Mark deprecated mapping semantics; add historical limit hint - Cover async omit/false/deprecation paths and epsActual alias parsing * fix(intelligence): keep deprecated mapping descriptions within 150 chars EndpointSemantics.natural_description is max_length=150; the longer deprecation copy broke langchain test collection on CI. * docs(changelog): correct Unreleased notes and cut 2.4.0 section (#118) - Drop overclaimed lockfile package bumps (uv.lock is gitignored; pyproject floors for mcp/ruff/mypy did not change in this release) - Document Actions/pre-commit/nox security changes that actually shipped - Align deprecation notes with soft-fail behavior and bmo/amc vs HH:MM - Move MCP DEFAULT_TOOLS hygiene under Changed; list dropped tool keys - Cut ## [2.4.0] for #97/#104/#105 which already shipped via #106 - Remove stale "866 unit tests" count from an older Unreleased bullet * fix(ci): address CodeRabbit findings for release PR #113 (#119) * fix(ci): address CodeRabbit findings for release PR #113 - Disable checkout credential persistence on docs/TestPyPI build jobs - Disable setup-uv caching on publish/release workflows (cache poisoning) - Make @deprecated preserve iscoroutinefunction for async methods - Clarify deprecated earnings docs; drop VCR from soft-fail integration tests * ci: re-trigger checks after GitHub Actions outage flakes * ci: re-trigger PR 113 checks after stuck Actions jobs * ci: re-trigger Test-Matrix for PR 113 (Actions backlog) * ci: re-trigger required Test-Matrix for PR 113 * test(intelligence): annotate earnings tests for PR #113 polish (#124) * test(intelligence): annotate earnings report-time tests for release polish Address remaining CodeRabbit outside-diff feedback on PR #113 by adding return and fixture type annotations to the new earnings calendar/historical and soft-fail tests, without expanding into unrelated test hygiene. * test(intelligence): hoist ClientConfig to module-level import Code review nit: the annotation pass moved FMPDataClient to a module-level import but left ClientConfig as a function-local import in the same fixture. Both are re-exported from fmp_data/__init__.py with no circular-import distinction, and sibling unit tests import these at module level, so the split was a half-finished cleanup rather than a deliberate choice. Refs: #113 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DEft1H9wesDFusXsHjoPQH --------- Co-authored-by: Claude Opus 5 (1M context) * fix(mcp): repair intelligence mapping drift and wire grades/ratings tools (#114, #115, #116) (#120) * fix(mcp): repair intelligence mapping drift and wire grades/ratings tools Closes #114, closes #115, closes #116. #114 - semantics method_name values that no client method implements make register_from_manifest fail even though discovery advertises the tool: - intelligence: get_crowdfunding_search -> search_crowdfunding, get_equity_offering_search -> search_equity_offering (endpoint-map keys renamed to match, since those keys are method names) - the new guard test found the same drift in neighbouring clients: market.search -> search_company, institutional get_cik_mapper -> get_cik_mappings (semantics key cik_mapper -> cik_mappings) and get_cik_mapper_by_name -> search_cik_by_name #115 - removed intelligence.institutional_holders and intelligence.financial_reports_dates, which advertised methods owned by the institutional and fundamental clients. They could never register and their key-only tool names collided with the real tools. #116 - added INTELLIGENCE_ENDPOINT_MAP entries and semantics for the grades/ratings surface (ratings_snapshot, ratings_historical, price_target_news, price_target_latest_news, grades, grades_historical, grades_consensus, grades_news, grades_latest_news) and added all nine to DEFAULT_TOOLS, plus the now-working crowdfunding_search and equity_offering_search. Tests: every discovered tool's method_name must resolve on a live client via _resolve_attr (was 7 broken, now 0), plus no-ghost, endpoint-map consistency, DEFAULT_TOOLS key uniqueness and grades/ratings presence checks. Docs: docs/mcp/tools.md Intelligence 39 -> 45 tools; Institutional cik_mapper renamed to cik_mappings. Co-Authored-By: Claude Opus 5 (1M context) * fix(mcp): harden mapping drift guards and LC semantics pairing Make fmp_data.lc defer langchain-heavy imports so domain mapping modules and MCP discovery work with the mcp extra alone. Pair endpoint-map keys with semantics by method_name (not only get_ strip) so search_* renames from #114 remain joinable for LangChain. Guard tests now fail if discovery is empty and cover the renamed search method pairing. Clarify CHANGELOG for #116 as MCP-first. * ci: re-trigger checks after GitHub Actions queue stall * ci: re-queue Test-Matrix after GitHub Actions stall * ci: queue checks for post-outage recovery * ci: re-queue Test-Matrix after partial Actions recovery * ci: re-queue after 30m+ without Test-Matrix on head * ci: re-queue Test-Matrix (partial Actions recovery observed) * ci: re-queue Test-Matrix after CodeQL recovery * ci: re-queue Test-Matrix (no TM run since 18:03 UTC) * fix(mcp): align grades_news response hint key with model field Code review found the grades_news semantics entry advertising a `publisher` response hint, while StockGradeNews exposes the field as `news_publisher`. The hint key feeds the LangChain vector-store embedding string, so a key that matches no model field weakens semantic search for that endpoint. `publisher` is kept as a related term. Verified: this was the only response-hint key among the nine new grades/ratings entries that did not match its response model. Ten pre-existing intelligence entries carry the same class of drift; those are tracked in #123 rather than widened into this PR. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fh8pKLXs1J7Ho1ip272y5d * fix(lc): match endpoint categories by longest prefix (#122) `ValidationRuleRegistry.validate_category` took the first registered group whose prefix matched the method name. Because endpoint-map keys *are* method names, the owning group always contributes an exact match — but an earlier group holding a shorter prefix won instead. Company's `get_price_target` therefore claimed intelligence's `get_price_target_news` and `get_price_target_latest_news`, rejecting both with a bogus "belongs to Company Information" mismatch and aborting the whole intelligence group in `register_batch`. Prefer the longest matching prefix, which is always the owning group's. Also from review: - Restore `fmp_data.lc.Endpoint` / `fmp_data.lc.Embeddings`, which the lazy-import rework silently moved behind `TYPE_CHECKING`, and add a `__dir__` so the lazily-exported names stay visible to REPL completion. - Correct the CHANGELOG claim that the `method_name` pairing fix restores LC discoverability; it repairs the pairing helper only, with the group-level abort still tracked in #121. Document the `create_vector_store` default-argument change. - Add regression tests: the nine grades/ratings tools now assert real `register_from_manifest` registration and LC registry acceptance, and key ambiguity is checked against the full discovery catalogue rather than `DEFAULT_TOOLS` alone (pre-existing `crypto_quotes` / `forex_quotes` collisions allowlisted, tracked in #126). Closes #122. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017u7uHDkC69sLmn2qkCxADa --------- Co-authored-by: Claude Opus 5 (1M context) * Build(deps): Bump pypa/gh-action-pypi-publish from 1.14.1 to 1.14.2 (#132) Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.14.1 to 1.14.2. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.14.1...v1.14.2) --- updated-dependencies: - dependency-name: pypa/gh-action-pypi-publish dependency-version: 1.14.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix(lc): resilient endpoint registration, semantics drift, docs sync, and typed tests (#121, #122, #123, #125) (#127) * fix(lc): register endpoints resiliently and repair semantics drift (#121, #123) EndpointRegistry.register_batch() raised on the first endpoint that failed validation, so setup_registry() skipped the remainder of that client group. Only 73 of 159 endpoints reached the live registry. register_batch() now registers every valid endpoint, collects failures, and returns them as a {endpoint_name: error} mapping; setup_registry() logs a per-group summary of what it skipped. Fixes the 29 endpoints that failed validation: - adds missing parameter hints (analyst_estimates, intraday_prices, employee_count, geographic_revenue_segmentation, owner_earnings, market_hours, search_by_cik, sector/industry performance and PE, form_13f, institutional_hold{ers,ings}, insider_trades, cik_mapper*, equity_offering_rss) - drops hints for parameters the endpoints do not accept - corrects aftermarket_trade/quote and stock_price_change to the Company Information category matching the client they live on - historical sector/industry endpoints hint the real from/to query params Also repairs two lookups this exposed: market "search" semantics pointed at a method MarketClient does not have (it is search_company, and the spec is in DEFAULT_TOOLS), and setup_registry now falls back to matching semantics by method_name, which pairs investment.search_fund_disclosure_holders. InstitutionalOwnershipDates.cik becomes a declared optional field. Guard tests assert the whole catalog pairs with semantics and validates, so future drift fails in CI. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01E8qZ4JwoNmyN9QRixanRAs * chore(typing): type-check tests/ with mypy (#125) tests/ was excluded from mypy, so every annotation in a test file was documentation-only and machine-unverified -- the review cost of demanding annotations without the guarantee they exist to provide. Removes the exclude and adds a tests.* override relaxing disallow_untyped_defs / disallow_incomplete_defs, so wrong annotations are caught without requiring complete ones. Fixes the 76 errors this surfaced: narrowing asserts for Optional and union values, explicit annotations where a Mock left a TypeVar unbound, targeted type: ignore for deliberate invalid constructions and method patching, and removal of stale ignores. No test behavior or assertions changed. nox -s typecheck now runs mypy over fmp_data and tests. Remaining per the issue: tighten the override directory by directory until tests/ inherits the project's strict settings. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01E8qZ4JwoNmyN9QRixanRAs * docs(changelog): record registry, semantics, and test-typing fixes Covers #121, #123, and #125 under Unreleased. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01E8qZ4JwoNmyN9QRixanRAs * docs(mcp): complete the tools reference and guard it against drift (#123) docs/mcp/tools.md omitted four whole client sections -- batch (30), index (6), sec (12), transcripts (4) -- plus company.profile_cik, and its per-section counts were stale. Adds the missing sections and states two numbers everywhere: catalog tools (everything with MCP semantics, loadable via an explicit manifest) and how many are in DEFAULT_TOOLS. The convention is explained once in the header rather than left for the reader to infer from a mismatch. A guard test asserts every key from discover_all_tools() appears in the doc and that the header totals match DEFAULT_TOOLS, so the reference cannot drift silently again. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01E8qZ4JwoNmyN9QRixanRAs * docs(changelog): correct registry counts and record the register_batch contract change Review of #127 found three factual problems in the changelog entries: - The registry counts were wrong in both numbers. Measured by replaying the old abort-on-first-failure path against origin/dev and the new path against HEAD: 75 of 168 registered before, 168 of 168 after. "159" was len(DEFAULT_TOOLS) -- an MCP number that has nothing to do with the LangChain registry -- and 73/29 were measured before #120 added the method_name fallback to resolve_semantics_for_endpoint. - The "broken market.search MCP tool" entry described work that already shipped on dev in #120. Neither the method_name nor the fallback is in this diff; the bullet double-counted it. Removed. - register_batch() went from `-> None` + raises to `-> dict[str, str]` + never raises. That is a public signature change filed under Fixed, which reads as "no consumer action required". Added a Changed entry stating the old and new contract and the actual blast radius. Also documents the two semantics fixes that follow in this branch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KbwQTKm67bV4RHL1GNF8wN * fix(lc): sync the intraday_price alias semantics and guard alias drift (#123) COMPANY_ENDPOINTS_SEMANTICS carries two entries for get_intraday_prices: "intraday_prices" and the "intraday_price" alias. Only the first is reachable from the endpoint map, so registry validation never looked at the alias and it still hinted just symbol/interval while its endpoint takes start_date, end_date and nonadjusted as well. The alias is not dead code -- it is in DEFAULT_TOOLS, so the shipped MCP tool documented three fewer parameters than it accepts. The existing catalog guard iterates endpoint-map first, which is why it could not see this. Added test_alias_semantics_match_their_endpoint, a semantics-first sweep over entries no endpoint key selects. Entries whose method_name has no endpoint at all are skipped -- get_company_logo_url builds a URL client-side without calling the API. Verified the guard is not vacuous: reverting the mapping hunk fails it with missing=['end_date', 'nonadjusted', 'start_date']. Also switched test_every_endpoint_passes_registry_validation to one shared registry across groups, matching setup_registry, and asserted the surviving count. A per-group registry cannot see a cross-group endpoint name collision, which in production silently overwrites the first entry. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KbwQTKm67bV4RHL1GNF8wN * fix(lc): point company price/quote semantics at the company client (#123) Eight COMPANY_ENDPOINTS_SEMANTICS entries -- quote, simple_quote, intraday_price, intraday_prices, historical_price, historical_prices, market_cap, historical_market_cap -- declared client_name="market", but every one of those methods lives on CompanyClient; client.market.get_quote and friends do not exist. This is the same client/category confusion the branch already corrected on aftermarket_trade, aftermarket_quote and stock_price_change, just on the sibling field that was left behind. Impact is metadata-only: nothing dispatches on client_name (MCP resolves tools through the module slug), which is why registry validation never caught it. Added test_client_name_matches_owning_group so it cannot drift back. Verified all 32 company semantics method_names resolve on CompanyClient. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KbwQTKm67bV4RHL1GNF8wN * test(lc): run the registry guards without the langchain extra tests/unit/lc/conftest.py importorskips langchain_core, langchain_community and faiss for the whole directory, so the #121 regression guard and the catalog drift guards only ran in CI's langchain session. Neither file touches langchain: importing fmp_data.lc.registry, fmp_data.lc.models, the mappings and resolve_semantics_for_endpoint pulls in no langchain or faiss module at all. That gating is the failure mode this branch exists to prevent -- if faiss ever failed to build on a platform, the guard against "the registry silently drops most of the catalog" would vanish silently along with it. Moved both files to tests/unit/. Verified in a core-only venv (no langchain_core, no faiss): the two files run 7 passed, while tests/unit/lc/ skips wholesale as before. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KbwQTKm67bV4RHL1GNF8wN * test(docs): make the tools.md guard bidirectional and duplicate-safe (#123) The guard compared bare tool keys and only checked catalog - documented. Two gaps: - crypto_quotes and forex_quotes each exist under two clients (alternative and batch), so 224 catalog entries collapsed to 222 unique keys. Deleting either of the two rows left the other satisfying the assertion. Verified: removing the batch crypto_quotes row passed the old test and fails the new one with ['batch.crypto_quotes']. - Nothing checked documented - catalog, so a row for a deleted tool lingered forever. Verified: an invented row now fails with ['batch.bogus_removed_tool']. Everything now compares on the fully-qualified client.key spec. Added stale-row and duplicate-row assertions, and per-section count checks against the catalog for both the section headings and the table of contents -- previously only the two header totals were guarded, leaving all 13 per-section counts free to drift. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KbwQTKm67bV4RHL1GNF8wN * chore(typing): drop the dead conftest mypy override and fix a misleading comment - pyproject.toml listed "conftest" alongside "tests.*" in the tests mypy override. Every conftest resolves as tests.conftest / tests.unit.lc.conftest and is already matched by tests.*, so mypy reported the section as unused. Removing it drops "conftest" from the unused-section note; mypy stays clean at 190 files. The remaining entries there are ignore_missing_imports guards for optional extras and are load-bearing when those extras are absent. - tests/integration/test_investment.py claimed "an empty list has been observed for unknown symbols", but the type: ignore[unreachable] on the next line is mypy proving that branch cannot happen: get_etf_info returns a model, so the assertion reduces to `result is None`. Reworded to say what the branch is actually for rather than asserting an observation the types contradict. The assertion itself is unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KbwQTKm67bV4RHL1GNF8wN * docs(changelog): record that setup_registry now propagates non-validation errors setup_registry() is exported from fmp_data.lc and previously wrapped each group's registration in `except Exception: logger.error(...)`. The #121 fix removed that handler, so validation failures are absorbed per endpoint by register_batch() while anything else now propagates. That is the intended behaviour -- a group that cannot load at all should fail loudly rather than resemble a group with no endpoints -- but it is a user-visible change on a public function and was not written down. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KbwQTKm67bV4RHL1GNF8wN * fix(lc): stop advertising capabilities two intelligence tools do not have Making all 27 previously-failing endpoints register turned two intelligence entries into live LangChain tools that misrepresent themselves. stock_news: removing the dead `tickers` hint was correct — STOCK_NEWS_ENDPOINT takes only page/start_date/end_date/limit — but the description still said "stock-specific news" and the lead example query was "Get stock news for AAPL". LC's create_tool calls client.request(endpoint, **kwargs) directly rather than the sync client method that delegates on `symbol`, so a symbol-scoped query matched a tool that silently ignores the symbol and returns the global feed. It is in DEFAULT_TOOLS, so this was user-facing on MCP too. stock_news_sentiments: the client emits a DeprecationWarning and returns [] without calling upstream, and docs/mcp/tools.md already flags it — the semantics table was the one place that did not, so the vector store surfaced it for "market sentiment data" and returned an empty success. Marked deprecated in the description and retargeted its matching terms so it stops competing for live sentiment queries. Registry still enrolls 168/168 with 0 failures. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MczR69sDwaWFDBwf5f2n1L * chore(lc): drop NAME_HINT, left dead by this branch This PR removed `cik_mapper_by_name`'s `name` hint (the endpoint has no such parameter), which was NAME_HINT's only consumer. Ruff does not flag unused module-level constants, so it would have lingered silently. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MczR69sDwaWFDBwf5f2n1L * fix(lc): stop logging tolerated endpoint skips at ERROR with a traceback register_batch()'s new contract treats a per-endpoint validation failure as a skip and reports it once at WARNING. But register() still logged every EndpointInfo construction failure at ERROR with exc_info=True — and pydantic's ValidationError subclasses ValueError, so a malformed pair emitted an error-level traceback and was then silently absorbed into the failures dict. Split the two cases: ValueError (the tolerated, reported-by-caller path) logs at DEBUG like the adjacent validation-failure branch; anything else keeps the ERROR and the traceback, because it escapes register_batch and does abort the batch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MczR69sDwaWFDBwf5f2n1L * docs(changelog): correct two register_batch claims and record the tool fixes Review falsified two statements in the #121 entries: - "EndpointRegistry is not exported from fmp_data.lc" is false. lc/__init__.py imports it at module scope, so `from fmp_data.lc import EndpointRegistry` resolves today; it is merely absent from __all__. The external surface of the signature change is wider than the note claimed. - The fail-loud example was unreachable. get_endpoint_groups() at the top of setup_registry() was never inside the removed handler, so an import error from it always propagated. The path where the change actually bites is the lazy get_endpoint_groups() inside _ensure_validation_initialized(). Also noted that create_vector_store still wraps this in a blanket `except Exception` and returns None, so the loud failure does not yet reach that caller (#133), and recorded the two intelligence tool fixes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MczR69sDwaWFDBwf5f2n1L * test(lc): guard against a whole client module leaving the LC registry Every guard in test_catalog_registration.py derives its universe from get_endpoint_groups(), so none of them can see a client module that is absent from it entirely -- the same silent-disappearance shape as #121, one level up. MCP discovery knows 13 clients; the registry enrolls 9. Pin the difference against discovery as an explicit LC_EXCLUDED_CLIENTS set ({batch, index, sec, transcripts}, tracked in #129) so the exclusion is declared rather than incidental. Dropping a 10th group, or adding a 14th client without enrolling it, now fails here. Verified non-vacuous by removing `technical` from the group map and confirming the guard fails naming it. Two smaller repairs: - test_every_endpoint_passes_registry_validation counted every endpoint-map key, including unpaired ones, so a lost semantics entry would surface as a bogus "name collision" instead of the real cause. Count what was actually handed to the registry. - The vector-store fixture discarded register_batch's return. Since it no longer raises, a drifted fixture pair would leave the registry empty and fail dependent tests with a confusing symptom rather than at the source. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MczR69sDwaWFDBwf5f2n1L * test(lc): close the guard holes that still let an endpoint vanish Review broke each guard to see which ones do not guard. Three gaps: 1. Deleting an entry from a client's endpoint map left the whole suite green. Every guard here iterates the endpoint map, so removing an entry just shrinks the universe they check -- setup_registry silently dropped 168 to 167 and nothing failed. That is precisely the #121 regression class this file exists to catch. test_every_semantics_entry_has_an_endpoint now looks the other way, semantics to endpoints, against an explicit CLIENT_SIDE_METHODS allowlist (company_logo_url is the only legitimate orphan, measured across all nine groups). 2. Nothing asserted DEFAULT_TOOLS is a subset of the catalog. A phantom entry failed only the per-section count tests, and every message blamed the doc -- so bumping the doc count made CI green while leaving a manifest entry that fails at MCP server startup. 3. The alias guard compared parameter_hints only, so a wrong SemanticCategory on an alias-only entry passed everything. Inert today, but the alias is a live MCP tool and client_name is already checked, so the omission was inconsistent rather than deliberate. Also pinned three register_batch behaviours the contract claims but nothing tested: an empty batch is a no-op; a structurally malformed pair is a skip rather than a crash (pydantic's ValidationError subclasses ValueError, so "never raises on validation" covers it) and the rest of the batch still registers; a non-ValueError propagates instead of being filed as a skip. Each new guard verified non-vacuous by introducing its drift and confirming the specific failure. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MczR69sDwaWFDBwf5f2n1L * chore(tests): narrow a blanket call-arg ignore, and note the action bump The `# type: ignore[call-arg]` on the EarningsTranscript constructor suppressed every argument error, so a field misspelled later would have been hidden too. model_validate needs no suppression and keeps the check. CHANGELOG: gh-action-pypi-publish has since moved to v1.14.2 (#132), so the Unreleased notes describe what will actually ship. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MczR69sDwaWFDBwf5f2n1L * chore(tests): cast the endpoint group maps to their real element types `cast(dict[str, Any], ...)` widened the element type, so anything read out of these maps was unchecked. ENDPOINT_GROUPS mixes the maps with a `display_name` string, so its value type really is heterogeneous and a cast is unavoidable -- but it can name the actual element types instead of Any. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MczR69sDwaWFDBwf5f2n1L --------- Co-authored-by: Claude Opus 5 (1M context) * docs(spec): design for remediating the nine open issues after #127 Records the four-PR plan, the two live API probes that grounded #130 and #131, and the decisions on tool-key namespace policy and the create_vector_store error contract. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MczR69sDwaWFDBwf5f2n1L * docs(changelog): cut the 2.5.0 release section Renames Unreleased to [2.5.0] with a summary of what the release contains, and opens a fresh Unreleased. Also consolidates the duplicate Added/Changed/Fixed headings that accumulated as #113, #120 and #127 each appended their own blocks, so the section reads as one changelog rather than three concatenated ones. No entries were dropped or reworded beyond removing a now-stale forward reference to #121. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01E8qZ4JwoNmyN9QRixanRAs * docs(spec): settle CIK padding and hint-conflict decisions D5: int CIK coerces to a 10-digit zero-padded string, matching every probed endpoint; strings are left untouched. D6: conflicting ParameterHint patterns take the union, with each genuine conflict recorded in the PR body. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MczR69sDwaWFDBwf5f2n1L * docs(spec): settle padding decision and record issues already closed D5: int CIK coerces to a 10-digit zero-padded string, matching every probed endpoint; strings left untouched. D6: conflicting ParameterHint patterns take the union, with each genuine conflict recorded in the PR body. #121, #123 and #125 were closed by hand shortly after #127 merged, so the "close by hand" action item was stale. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MczR69sDwaWFDBwf5f2n1L * docs(spec): retarget deprecations to 2.6.0 after 2.5.0 was cut The 2.5.0 CHANGELOG section was cut in 525a0bb, so all four PRs land under Unreleased (2.6.0). Also records the pre-existing v2.4.0-tag vs 2.5.0-changelog drift, which must be resolved before 2.6.0 is cut. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MczR69sDwaWFDBwf5f2n1L * fix(lc): apply longest-prefix rule selection in get_expected_category CodeRabbit on #113: validate_category was changed to pick the most specific matching prefix (the #122 fix), but get_expected_category still returned the first match. For overlapping prefixes the two public methods disagreed on which category owns an endpoint -- the same defect #122 fixed, still live in the sibling method. Extracts _find_matching_rule and routes both methods through it, so ownership cannot depend on which entry point asks. Adds a test that fails on the old first-match behaviour, and annotates the fixture params on the neighbouring longest-prefix test. Note: fmp_data/lc/validation.py carries an unreferenced near-duplicate of ValidationRuleRegistry with the same first-match logic. Nothing imports it; left alone here rather than fixing dead code during a release. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01E8qZ4JwoNmyN9QRixanRAs * fix: uncompilable enum regex and integer CIK rejection (#134, #131) (#138) * docs(plan): PR1 implementation plan for #134 and #131 Six TDD tasks covering the enum regex fix, the shared CIK coercing type, its application to all 46 cik fields, and non-vacuity checks for both guards. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MczR69sDwaWFDBwf5f2n1L * docs(plan): reword stale in-flight constraint after d9a37cc landed Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MczR69sDwaWFDBwf5f2n1L * fix(lc): drop extra paren in enum string type pattern (#134) _get_type_pattern emitted '^(annual|quarter))$', which raises re.error on the uncaught re.match in validation.py. Adds a guard that compiles every pattern the registry can emit across all 168 registered endpoints. * fix(lc): use enum .value in string type patterns (#134) economics.get_economic_indicators declares valid_values as enum members, so map(str, ...) produced 'EconomicIndicatorType.GDP' and the pattern rejected every real value. Also escapes alternatives, since valid_values is endpoint-declared data rather than trusted regex source. * feat(models): add shared CIK type coercing int to zero-padded str (#131) Every probed endpoint returns cik as a 10-digit zero-padded string, but JSON producers drop leading zeros. CIK coerces int -> '%010d' so callers joining on cik across endpoints get one spelling. Strings pass through. * fix(models): route all 46 cik fields through the CIK type (#131) A bare str annotation rejects an integer CIK before validation_mode is consulted, since _validate_model calls model_validate first. Adds a guard so a new model cannot reintroduce a bare-str cik. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BhgYVk4CNGKaHjYqg4tajN * docs(plan): correct cik field count to 45 across 8 modules The enumerated file list was right; the prose summary said 46 across 9. Caught by the Task 4 implementer. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MczR69sDwaWFDBwf5f2n1L * test(models): widen the cik guard to *_cik and assert an inspection floor (#131) The guard only looked at model_fields["cik"], so the 13 sibling reporting_cik / company_cik / targeted_cik / intermediary_commission_cik fields carried the same bare-str defect unseen. Widens it to every field named cik or *_cik, converts those 13 to the CIK type, and names the field in the offender string since a class can hold several. Also adds an inspection floor: walk_packages(onerror=None) swallows ImportError, so an extras-gated subtree can vanish silently and leave the guard passing having inspected fewer fields, or none. Mirrors the checked > 100 floor on test_every_emitted_pattern_compiles. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BhgYVk4CNGKaHjYqg4tajN * docs(institutional): record that ownership/dates omits cik (#131) Probed 2026-08-07: the endpoint returns only date, year and quarter. Keeps the next reader from re-deriving this against the live API. * docs(changelog): record #134 and #131 fixes Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01M381vvw2x5rxPZqjJoeuVj * docs(changelog): correct #134 behaviour-change claim The prior wording said patterns 'always raised on use' yet also 'let a value slip through unvalidated' — self-contradictory, since a hard re.error path validates nothing. Replace with the verified reality: no in-repo caller reaches these patterns today, so default paths see no behaviour change; an external caller of the public ValidationRuleRegistry.get_parameter_requirements now gets working, case-sensitive validation instead of a crash. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01M381vvw2x5rxPZqjJoeuVj * fix(lc): normalise enum valid_values at EndpointParam construction Review of #138 found that the new enum handling in `EndpointBasedRule._get_type_pattern` passes `value.value` straight to `re.escape`, which raises TypeError for any Enum whose value is not a str. The pre-#134 code (`str(value)`) produced a wrong pattern for that input but never crashed, so the fix traded a wrong-pattern bug for a crash bug in the very function it was hardening. Unwrap enum members once, at the source: `EndpointParam.__post_init__` replaces each member with its wire value, so neither consumer of `valid_values` has to special-case Enum. Only Enum is unwrapped -- values keep their native type, because `validate_value` compares the *converted* request value against this list and an INTEGER param such as `transcripts.quarter` (`valid_values=[1, 2, 3, 4]`) converts to int. Stringifying the list would make every such comparison fail. `_get_type_pattern` keeps its Enum branch, now via `str(value.value)`, for direct callers of the staticmethod. Both guards verified non-vacuous: reverting the `str()` reproduces the TypeError, neutering `__post_init__` fails the unwrapping assertion. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015N8Cd7k5gcraaAnFd48KpE * fix(cik): accept integer CIKs on the request path via ParamType.CIK #131 fixed the response path only. Outbound, every cik parameter was ParamType.STRING, so `cik=320193` was stringified to "320193" without padding -- a request that succeeds and returns nothing, which is worse than the inbound rejection it mirrors. The pydantic arg models used by the LangChain tools (`Form13FArgs`, `Form13FDatesArgs`, `PortfolioDateArgs`) rejected an integer outright via `cik: str` plus `pattern=r"^\d{10}$"`. Make CIK a first-class parameter type rather than threading coercion through 26 client signatures: - `ParamType.CIK` with `_convert_to_cik`, applied to all 16 cik EndpointParam declarations across 6 modules. - Unlike the response coercer, this pads numeric *strings* too. Inbound, re-padding would misreport what the API sent; outbound the padded form is simply the correct request. Non-numeric strings pass through so a bad value surfaces as an API error instead of being mangled into one. `bool` is rejected, matching pydantic's response-side behaviour. - Arg models use the `CIK` type, so the BeforeValidator pads before `pattern` is checked and the existing constraint still holds. - Client and async_client signatures widened to `str | int`; the local params dicts widened to match. Conversion stays at the endpoint layer. - `ToolFactory.PARAM_TYPE_MAPPING` maps CIK to `str` (an int mapping would strip the leading zeros the API matches on), and the registry emits `^\d{1,10}$` -- matched against the caller's raw value, before padding, so an unpadded 320193 must pass. Guard test asserts no cik param may revert to STRING; verified non-vacuous by reverting one declaration. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015N8Cd7k5gcraaAnFd48KpE * test(cik): widen the drift guard past *.models modules The guard's docstring claimed every CIK-valued field routes through the coercer, but it only walked modules named `*.models`. Request-argument models live in `*.schema` and carry cik fields too, so the claim was broader than the check — and the gap sat in exactly the place drift had already happened: `Form13FArgs`, `Form13FDatesArgs` and `PortfolioDateArgs` were all bare `str` and invisible to it. Walk every fmp_data module. Verified non-vacuous by reverting `Form13FDatesArgs.cik` to bare `str`, which the old walk did not see. Two follow-ons from widening the walk: - Models imported into several modules appear in each one's `dir()`, so fields are now keyed on their defining module. The floor counts distinct fields rather than sightings. - `fmp_data.investment.schema` cannot be imported under pydantic 2.13 (`ETFHoldingsArgs` declares `date: date`). That is pre-existing on dev and filed as #139. It is carried as an explicit allowlist entry rather than a silent skip, so a newly un-importable module fails this guard instead of quietly dropping out of coverage — also verified non-vacuous by emptying the allowlist. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015N8Cd7k5gcraaAnFd48KpE * docs(changelog): record the request-path CIK fix and #139 Covers ParamType.CIK, the request-vs-response padding asymmetry and why it is deliberate, the widened drift guard, and the pre-existing investment.schema import failure now tracked in #139. Also corrects the #134 entry, which described the enum unwrapping as happening in the pattern builder — it now happens once at EndpointParam construction. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015N8Cd7k5gcraaAnFd48KpE * fix(test): make the widened cik guard work without optional extras a7c3108 widened the walk to every fmp_data module. That passes locally with `uv sync --all-extras` and fails every CI matrix job, because the core test job installs no extras and the sweep then hits three separate ways a module declines to load: - fmp_data.lc.* raises ModuleNotFoundError on langchain_core at import - fmp_data.lc.__init__ resolves exports lazily, so the same failure also arrives via getattr, after the module has imported cleanly - fmp_data.mcp.* calls sys.exit(1) when its extra is absent, raising SystemExit — a BaseException that `except Exception` does not catch Importing CLI entry points was the mistake, not their behaviour. Import only the two module kinds that define models, *.models and *.schema, and close the gap that narrowing leaves with an AST sweep: it reads every source file in the package and fails if a cik field is declared anywhere outside that set. The sweep imports nothing, so no extra can affect it, and it catches drift into a brand-new file that neither the original *.models walk nor the widened import walk could. Split into helpers to stay under the complexity limit. Verified under `uv sync` (1006 passed) and `uv sync --all-extras` (1118 passed), mypy clean. All three mutations re-checked with __pycache__ cleared between runs, after a stale .pyc masked a restore — the CIK/str swap is byte-identical in length, so mtime+size caching can reuse it: bare `str` on Form13FDatesArgs.cik, an emptied allowlist, and a cik field planted in company/client.py each fail the guard. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015N8Cd7k5gcraaAnFd48KpE * test(cik): cover the three patch lines codecov flagged Patch coverage was 97.03% with 3 lines uncovered, all from 135309f. Two distinct causes: - investment/client.py:107 and async_client.py:118 — get_mutual_fund_dates had no unit test at all, sync or async. Widening the signature touched those lines, so a pre-existing gap became a patch miss. - models.py:119 — the fall-through past the new ParamType.CIK branch to `raise ValueError("Unsupported type")`. Every member has a branch, so that guard is unreachable through normal use; the uncovered branch has simply been migrating to whichever conversion was added last (it sat on 117->121 before this PR). The client tests are the ones worth having: they assert end-to-end that `cik=320193` leaves as `"0000320193"` on the query string, which is the entire reason ParamType.CIK exists — a STRING param sends "320193" and FMP matches it against nothing. Also covers the unpadded-string case and that an omitted cik puts no cik on the query. The guard branch is exercised by calling convert_value with a non-member self, which is honest about it being defensive code rather than pragma-ing it away. Patch lines verified covered via coverage JSON. 1124 passed with extras, 1012 core-only, total coverage 86.94% -> 87.87%, mypy clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015N8Cd7k5gcraaAnFd48KpE * fix(investment): revert params widening in get_etf_holdings The perl that widened the local params dicts to `dict[str, str | int]` matched every `params: dict[str, str] = {"symbol": symbol}` in the investment clients, including get_etf_holdings — a method that takes no cik and had no reason to change. Scope creep, and it pulled an untested method into the patch, which is the one line codecov still flagged. Reverted in both the sync and async client. Every remaining widening is now in a method that genuinely accepts a cik: get_mutual_fund_dates, get_fund_disclosure, search_industry_classification. Audited the whole diff line-by-line against the coverage JSON rather than trusting the check status: no patch line or branch is uncovered. 1124 passed, mypy and ruff clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015N8Cd7k5gcraaAnFd48KpE --------- Co-authored-by: Claude Opus 5 (1M context) * fix(investment): make schema module importable under pydantic 2.13 (#139) (#140) * fix(investment): make schema module importable under pydantic 2.13 (#139) ETFHoldingsArgs and MutualFundHoldingsArgs each declared a field named `date` annotated with the `date` imported from datetime. Under pydantic 2.13 the field name shadows the type before the annotation is evaluated, so class construction raised PydanticUserError and the whole module was unreachable at runtime. Imports the annotation as `dt_date`, matching the convention already in fmp_data/technical/schema.py. The field is still named `date`, so the wire format is unchanged. Adds tests/unit/test_imports.py asserting every module imports, failing on any exception rather than only ImportError -- #139 raised PydanticUserError, so an ImportError-only guard would have missed it. Nothing in the suite imported this module, which is why it sat unnoticed. Empties the _KNOWN_UNIMPORTABLE allowlist added in #138, restoring CIK drift-guard coverage of investment.schema (61 -> 64 fields). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MczR69sDwaWFDBwf5f2n1L * test(imports): close the guard's blind spots and pin the fixed behaviour The import guard added for #139 was sound in principle but under-delivered on its coverage claim in three ways, each demonstrated by mutation rather than inferred: - The optional-extra exemption was keyed on the module *name*, so it applied even when the extra was installed. Appending a real bad import to fmp_data/lc/vector_store.py and fmp_data/mcp/server.py left the suite green -- in the very langchain/mcp-server CI sessions meant to catch it. It now keys on whether the extra is actually importable, and asserts the exempted set cannot silently grow beyond the four modules that genuinely need one. - walk_packages ran without onerror, so a package that fails to import silently drops its entire subtree. Breaking fmp_data/lc/__init__.py took the walk from 126 modules to 117 without a word; only collateral damage via unrelated importers made the test fail at all. - The floor of 50 sat at 40% of the true module count, so 60% of the package could vanish from the walk unnoticed. Raised to 120 against an actual 126. find_spec is also allowed to raise rather than return None, which a meta_path finder can do; that now counts as absent instead of erroring. Adds the missing behavioural test for the fix itself. Nothing constructed ETFHoldingsArgs or MutualFundHoldingsArgs, so renaming the field to dodge the shadowing -- a silent wire-format break -- would have passed every test. The field name, its datetime.date annotation and ISO parsing are now pinned. Corrects a CHANGELOG claim: nothing in the package imports investment.schema, so no LangChain registration path was failing outright. That orphan status is why the break survived undetected. Verified in a real no-extras venv (the core CI session): 126 modules walked, 0 broken, 0 walk errors, exactly the 4 expected exemptions. Full suite 1126 passed, 4 skipped; ruff check and format clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0192aPPN5Ymz3JjmwnvXcfEn * fix(changelog): correct CIK coverage delta and drop a contested assertion Two corrections from the second review pass. The CIK drift-guard coverage went from 63 to 64 fields, not 61 to 64. Verified by running _cik_fields_via_import() at 5e7556d (63) and at HEAD (64). The 61 was carried over from #138's starting state; the delta this PR contributes is the single PortfolioDateArgs.cik field. The new arg-model test no longer asserts is_required(). ETF_HOLDINGS declares the date parameter optional while MUTUAL_FUND_HOLDINGS declares it mandatory, and both arg models declare it required -- a three-way contradiction now tracked in #143. Asserting requiredness would have pinned one side of it and made the eventual reconciliation harder. The test keeps what the fix actually preserved: the field name, its datetime.date annotation, ISO parsing and JSON-schema format. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0192aPPN5Ymz3JjmwnvXcfEn --------- Co-authored-by: Claude Opus 5 (1M context) * fix(mcp): one tool key per (client, method) pair (#126, #130, #136) (#147) * fix(mcp): one tool key per (client, method) pair (#126, #130, #136) Settles what the MCP tool-key namespace guarantees. Remove what is dead now; deprecate live aliases over 2.6 -> 3.0. #130 - remove `institutional.cik_mapper_by_name` outright. `CIK_MAPPER_BY_NAME` declared the same `cik-list` path and the same page/limit params as `CIK_MAPPER` and no `name` param, so the generated tool could not express the search it claimed. A live probe (2026-08-07) confirmed `/stable/cik-list` has no server-side name filter: `name=Apple`, `company=Apple` and unfiltered returned byte-identical pages. Removed the semantics entry, the `search_cik_by_name` endpoint-map entry and the now-unreferenced endpoint definition. `InstitutionalClient.search_cik_by_name` (sync and async) is untouched and remains the genuine interface -- it fetches `limit=10000` and filters locally. Catalog 224 -> 223. #136 - deprecate the singular of each doubled key: `historical_price`, `intraday_price`, `executives` in favour of `historical_prices`, `intraday_prices`, `key_executives`. Declared in `tools_manifest.DEPRECATED_TOOLS`; both the bare and fully qualified forms keep resolving through 2.6 and emit a DeprecationWarning naming the replacement and the 3.0 removal, raised from the single path both forms funnel through. Removed from DEFAULT_TOOLS so the default server advertises one tool per method: 159 -> 156. Catalog reaches 220 at 3.0. #126 - state the bare-key guarantee instead of failing opaquely. A bare tool key resolves only when exactly one client claims it. `crypto_quotes` and `forex_quotes` are each claimed by `alternative` and `batch`; both tools are legitimate and distinct, so neither is removed or renamed. The error now names every candidate and the exact form to use. The guard test drops its shrinking allowlist and asserts the ambiguous set is exactly that pair, so a new undocumented collision still fails. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0182DooZGZs49Hvr7DxfXwkX * fix(mcp): address review findings on the tool-key namespace change - stacklevel 3 -> 4 on the deprecation warning. The chain is _warn_if_deprecated -> _resolve_tool_spec -> register_from_manifest -> caller, so 3 attributed the warning to tool_loader.py: it named the library instead of the line to change, and no caller-side filterwarnings rule could match. A test now asserts the warning's filename is the calling module. - Example manifests no longer name deprecated keys. trading_manifest listed historical_price AND historical_prices, intraday_price AND intraday_prices; research_manifest listed executives AND key_executives; minimal_manifest listed historical_price. docs/mcp/configurations.md tells users to copy these, so our own examples demonstrated the problem #136 exists to fix. - crypto_manifest.py listed bare crypto_quotes and forex_quotes, each claimed by two clients, so the shipped crypto example raised at registration (#126). Now written alternative.crypto_quotes / alternative.forex_quotes. - New guards over every shipped example manifest: no deprecated key, no method listed twice, every entry resolves. All three fail against the pre-fix files. - get_recommended_tools() recommended company.historical_price; a public helper must not hand out a key this release deprecates. - 3.0 breadcrumb test. fmp_data.__version__ is hatch-vcs derived and is the "0.0.0" fallback whenever the suite imports the source tree, which is what happens here, so it is checked only when it carries a real value; the CHANGELOG's released-major headings are the signal that actually moves in-tree and are what trip the test. - fmp-mcp validate reports deprecated specs and their replacements (exit 0 -- deprecated is valid, just not future-proof), warns on ambiguous bare keys naming the candidates, and resolves bare keys before judging them so a valid bare-key manifest is no longer reported as full of unknown tools. fmp-mcp list marks deprecated rows in every output format. - Documented the warning cadence: once per resolution, dedup delegated to Python's default filter, so each distinct key surfaces once per process. - Fixed the examples/mcp_configurations/ -> examples/mcp/configurations/ path across all five docs that carried it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0182DooZGZs49Hvr7DxfXwkX * fix(mcp): announce deprecations where the server operator can see them Review of the tool-key namespace change found the deprecation cycle's one job -- telling people to migrate before 3.0 breaks them -- was not being done on the path almost everyone uses. `stacklevel=4` attributes the DeprecationWarning to whoever called `register_from_manifest`. On the dominant path that caller is `fmp_data.mcp.server.create_app`, a library module, which `python -m fmp_data.mcp`, `fmp-mcp serve` and Claude Desktop all funnel through. Python's default filter chain is `default::DeprecationWarning:__main__` then `ignore::DeprecationWarning`, so a warning attributed to anything but `__main__` is discarded before it is printed. Verified: a real `create_app(["company.historical_price"])` under default filters emitted nothing at all. The warning is kept -- it is the right contract for direct callers and the only thing a downstream `filterwarnings` rule can match -- and the same message now also goes out at WARNING on the `fmp_data.mcp.tool_loader` logger, which lands on the server's stderr where MCP hosts collect it. No stacklevel could have fixed this anyway: the offending key usually lives in a manifest data file, not in a stack frame. `test_deprecation_survives_pythons_default_warning_filters` reproduces those exact filters, asserts the warning *is* swallowed (so the test keeps proving something) and that the log record still carries key, replacement and the 3.0 target. Removing the `logger.warning` fails it. `test_canonical_keys_are_silent_on_both_channels` pins that the new channel does not turn every canonical key into noise. Also from review: - CHANGELOG: the `DEFAULT_TOOLS` reduction is breaking for default-server users in a minor release, and said so only implicitly -- it claimed "only manifests naming a deprecated key explicitly are affected", which is not true of someone whose saved prompt names the tool `executives`. The break is deliberate (advertising two identical tools per method is the defect #136 reports, and cannot be fixed while both are advertised) but those users cannot be warned in code: they never name the key, so no resolution happens and no call path exists on which to warn. Now stated plainly, with the rename to apply and the manifest escape hatch. - `docs/api/endpoints.md`: TOC still said Institutional (25 endpoints); the section heading and the actual count are 24. - `docs/mcp/configurations.md`: the new bare-key section stopped short of the failure a reader hits next -- naming *both* halves of a colliding pair still fails under the default `key` name style. Points at the `FMP_MCP_TOOL_NAME_STYLE=spec` tip that was already on the page. - `test_catalog_registration.py`: docstring still justified itself with `intraday_price` sitting in `DEFAULT_TOOLS`, which this branch changed. - Design spec said catalog → 218 in 3.0; it is 220. - Dropped a redundant local `import warnings`. Filed rather than folded in, to keep this change narrow: - #148 `fmp-mcp generate` emits a manifest with deprecated keys that also cannot start a server (full specs still collide under `key` style) - #149 bare-key resolution is implemented three times and can drift, so `fmp-mcp validate` could bless manifests the loader refuses Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NFHbRB7PUcEaPT1kaeJn9p --------- Co-authored-by: Claude Opus 5 * fix(lc): optional params in tool schemas, and register the four unguarded clients (#128, #129) (#145) * fix(lc): stop marking optional endpoint params as required in tool schemas (#128) ToolFactory.create_parameter_fields built every parameter as Field(description=...) with no default, so pydantic marked optional endpoint parameters required. get_field_type(optional=param.default is not None) widened the type to `| None` but never supplied a default, so an LLM calling the tool had to invent values the endpoint did not want. Mandatory-ness is now threaded through explicitly: the helper takes mandatory_params and optional_params as separate arguments rather than one concatenated list. Neither of the two properties it could have inferred from agrees with list membership across the catalog -- 13 params sit in optional_params with required=True, and 13 more sit in mandatory_params carrying a default. Endpoint.validate_params already resolves this by list membership, so the tool schema now does too. Optional params get param.default as their pydantic default rather than None: validate_params marks a param seen before it skips a None value, so an explicit None would suppress the default it would otherwise apply. Guard test asserts the required-field set equals the mandatory-param set for every registered endpoint, with a floor on endpoints checked. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MAu1ruoSYKUm3xyZZZjFXC * fix(lc): register batch, index, sec and transcripts; fill 33 missing hints (#129) _get_endpoint_groups() registered nine client groups, so batch, index, sec and transcripts never reached EndpointRegistry.validate_endpoint() and nothing checked their parameter_hints. All four are now enrolled: the registry serves 216 endpoints, up from 168. The 33 endpoints declaring parameter_hints={} while taking real parameters (batch 22, sec 9, transcripts 2; index was clean) are filled, reusing fmp_data.lc.hints wherever a shared constant fit. Two new shared hints -- SYMBOLS_HINT and DATE_HINT -- cover a comma-separated ticker list and a single as-of day, neither of which lc/hints.py had; five module-local hints cover concepts no other client has. Adding the groups surfaced two things the modules had been free to get wrong while unguarded: - batch declares five categories and sec two, while a group carries exactly one: validate_category compares each entry's own category against the rule owning the longest matching prefix, and a group's endpoint-map keys are exact matches for its own endpoints, so its own rule always wins. The constraint binds the group, not the module, so groups are now derived by _partition_by_category -- one group per category a client declares -- instead of being hand-listed with a chosen category. No declared category changed: that field feeds the semantic search the registry exists to serve, and rewriting batch.income_statement_bulk to MARKET_DATA would have stopped it matching "fundamentals". Deriving rather than listing means a future category change repartitions instead of silently re-drifting. GroupConfig gains a `client` key, since a module may now span several groups; 18 groups over 13 clients. - Four method names are defined by two clients each. The registry is a flat name -> EndpointInfo map and LangChain tool names come from semantics.method_name, so two tools called get_profile cannot coexist regardless; left alone, sec's would have silently overwritten company's. They are declared in PENDING_COLLISION_EXCLUSIONS and skipped, and stay reachable through MCP, which addresses tools by client.key. That mapping is a stopgap pending #126 and #136, which settle the tool-key namespace with a deprecation cycle; nothing is renamed or deprecated here. resolve_semantics_for_endpoint moves from fmp_data.lc to fmp_data.lc.registry -- partitioning needs it before a group exists -- and is re-exported, so the old import path still works. Guard tests pin the one-category-per-group invariant, assert every endpoint routes to its own group's rule, assert partitioning is a no-op for all eleven uniform clients and lossless for the two that split, require every SemanticCategory to have a group slug, and require each exclusion to be a genuine clash whose hints still track its endpoint. LC_EXCLUDED_CLIENTS is now empty. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MAu1ruoSYKUm3xyZZZjFXC * test(lc): message the partition asserts and floor the category guard Three review minors from #128/#129. CHANGELOG overstated #128's blast radius: only endpoints declaring optional params emitted an over-required schema. Measured: 81 of the 216 registered endpoints, over 167 parameters; the other 135 are mandatory-only and were always correct. test_partitioning_is_a_no_op_for_uniform_clients compared the endpoint and semantics maps with bare asserts, so a dropped endpoint failed with an empty message. Every comparison in it now names the client and the symmetric difference; verified by mutation, a dropped economics endpoint now reports "economics: partitioning changed the endpoint map; symmetric difference: ['get_treasury_rates']". test_every_group_declares_one_category_and_its_semantics_agree asserts over a comprehension, so an empty group dict passed it vacuously. Added MINIMUM_SEMANTICS_CHECKED=200 against the 220 entries the catalog carries, matching the MINIMUM_ENDPOINTS_CHECKED idiom in tests/unit/lc/test_tool_schema.py. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MAu1ruoSYKUm3xyZZZjFXC * test(lc): pin the langchain contract that carries optional defaults Review found the one gap where a green suite could hide a real regression in the #128 fix. `create_parameter_fields` gives an optional param `default=param.default` rather than `None`, because `validate_params` marks a param seen before it skips a `None`, so an explicit `None` suppresses the endpoint's own default on the way out. That only holds if langchain forwards fields holding explicit defaults -- an implementation detail of `BaseTool._parse_input`, which has previously used the narrower `if k in tool_input` filter, in a dependency pinned `langchain-core>=1.4.9` with no upper bound. Every existing assertion inspects `model_fields[...].default`, so if that behaviour reverted, 64 optional params across the catalog would silently stop sending `period=annual`, `limit=40` and friends, and the whole suite would stay green. The new test drives a real `StructuredTool`, invokes it with only the mandatory param, and asserts on the kwargs the wrapped function actually received. Verified non-vacuous: defaulting optionals to `None` fails it with `{'period': None} != {'period': 'annual'}`. The docstring now names the coupling so it is greppable when someone bumps the pin. Also from review: - `DATE_HINT` -> `AS_OF_DATE_HINT` in `fmp_data.lc.hints`. The new shared constant sat one character and seven lines from the existing `DATE_HINTS` range mapping, with opposite meanings; `batch` was its only importer. - `SIC_CODE_HINT` dropped its `\b\d{4}\b` extraction pattern, which claimed any four-digit number in a query -- a year, a `limit=1000`. The `sic`-anchored pattern beside it loses nothing. - CHANGELOG: the two breaking notes (schemas' required-field sets shrank; `create_parameter_fields` signature) were filed as sub-bullets under `Fixed`. Anyone scanning for breaking changes reads `Changed`, so they are promoted there. - Design spec projected 168 -> 224 guarded; as built it is 216, and the line now says why the estimate was over. Not folded in, per #144, which already owns it: 13 params declare `required=True` while sitting in `optional_params`, so this change lets an LLM omit a date range on `get_crypto_historical` and friends. The new schema matches runtime exactly -- `EndpointParam.required` is read nowhere in `fmp_data/` and `validate_params` resolves requiredness by list membership -- so there is no inconsistency to fix here, but which way to reconcile the flag wants a live-API probe. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NFHbRB7PUcEaPT1kaeJn9p --------- Co-authored-by: Claude Opus 5 * fix(lc): consolidate hints, raise on vector-store failure, hide deprecated endpoints (#135, #133, #137) (#150) * fix(lc): consolidate duplicated ParameterHint constants into lc.hints (#135) Nine concept names were bound in more than one module with different contents, so the same parameter was described one way to a tool in `intelligence` and another way in `institutional`. Nothing compared them, so the divergence was invisible. Each concept now has exactly one definition in `fmp_data.lc.hints`. Unions were taken except where the union would produce wrong extraction: - PERIOD_HINT split -- technical's `periodLength` is an indicator lookback in bars, not a statement frequency. New PERIOD_LENGTH_HINT. - QUARTER_HINT keeps numeric examples; the wire param is INTEGER, so "Q1" would invite a 400. - CIK_HINT drops an unanchored `(\d{10})` that truncates longer digit runs. - SYMBOL_HINT drops an unanchored `[A-Z]{1,5}` that matches inside tokens. - PAGE_HINT narrows `p(\d+)` to `\bp(\d+)\b` ("sp500" read as page 500). - AS_OF_DATE_HINT drops a MM/DD/YYYY pattern; FMP takes ISO only. The DATE_HINT / DATE_HINTS collision is resolved: the two module-local DATE_HINT constants were the one-day as-of concept and fold into the existing AS_OF_DATE_HINT. Guard test walks every importable fmp_data submodule and fails on any name with two distinct definitions, with floor assertions and a separate must-import check so a module cannot drop out of the scan silently. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NrGnDrLoau5hKJFk9dc7nH * fix(lc): raise VectorStoreCreationError instead of returning None (#133) `create_vector_store` wrapped its whole body, `setup_registry` included, in `except Exception: return None`, cancelling out the fail-loud behaviour added in #121/#127. `None` also carried no information: the blanket handler was the only caller-reachable `return None`, so it unconditionally meant "something threw". - New `VectorStoreCreationError(FMPError)` in `fmp_data.exceptions`, carrying `cause` and the per-endpoint `failures` dict. Re-exported from `fmp_data`, so it is catchable without the langchain extra installed. - `create_vector_store` return type narrows to `EndpointVectorStore`. No None path, no deprecation shim, no strict= flag. - `setup_registry` returns a `RegistrySetup(registry, failures)` NamedTuple so `register_batch`'s failure dict is programmatically reachable instead of log-only (#133's closing note). - `try_load_existing_store`'s internal None returns are untouched; they mean "fall through to create", not "error". - Callers migrated: README (both examples), LLM.md, integration test fixture and creation test. Both breaks are documented in the CHANGELOG with a before/after snippet. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NrGnDrLoau5hKJFk9dc7nH * fix(lc): keep deprecated endpoints out of the vector store (#137) `intelligence.stock_news_sentiments`, `earnings_confirmed` and `earnings_surprises` return [] without calling upstream but stayed indexed, so a semantic query could pick one and the LLM got an empty success indistinguishable from "no data matched your query". - `EndpointSemantics.deprecated: bool = False`; the three are marked. - `EndpointVectorStore` filters on it at index time (`add_endpoint`, `add_endpoints`) and at selection time (`search`, `get_tools`). Index time alone would leave a store persisted by an earlier release still serving them. - The entries are not deleted: the MCP catalog stays at 223 and all three tool keys remain resolvable through an explicit manifest. `EndpointSemantics.deprecated` and `mcp.tools_manifest.DEPRECATED_TOOLS` are different mechanisms -- one marks a dead endpoint, the other renames a duplicate key for a live method. Both carry a comment saying so, and a test asserts the two sets stay disjoint. Also fixes two pre-existing vector-store test mocks: `Mock(spec= EndpointInfo)` does not expose pydantic field names, so `.semantics` had to be set explicitly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NrGnDrLoau5hKJFk9dc7nH * fix(lc): address review findings on hints, deprecation and error contract Guard the search() deprecation filter. It is the primary LLM-facing path (get_tools(query=...) delegates to it), but deleting the filter left all of tests/unit/lc green -- only the no-query branch was covered. The new test runs against the real registry, since SearchResult.info is typed EndpointInfo and rejects a stub before the filter is reached. Union back four content drops that the consolidation made without recording them as conflicts: SYMBOL_HINT's "company symbol"/"stock symbol" natural names (18 endpoints), CIK_HINT's three extra examples and its "filing ID"/"company id" natural names (12), and AS_OF_DATE_HINT's two extra examples (9). natural_names and examples feed get_embedding_text and generate_description, so dropping them shifts embedding text and tool descriptions with no stated reason. Widen the accidental-deprecation guard from the intelligence table to every semantics table, with a floor. Another domain could previously acquire deprecated=True and vanish from the vector store unnoticed. Correct "nine" to "ten" consolidated concepts in the CHANGELOG and the hints module docstring; both already enumerated ten. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MczR69sDwaWFDBwf5f2n1L * fix(tests): make the new guards pass in a core-only install CI installs no extras; these two files assumed otherwise, so they passed locally and failed all seven matrix jobs. test_hint_consolidation caught only Exception while walking the package. fmp_data/mcp/__main__.py calls sys.exit(1) when the mcp extra is absent, and SystemExit does not inherit from Exception, so it escaped the handler and failed the whole scan. Catch BaseException, as tests/unit/test_imports.py already does. test_vector_store_error_contract claimed in its docstring to need no langchain extra. It does: the monkeypatch targets resolve fmp_data.lc.embedding, which imports langchain_core. Skips cleanly now, and the docstring says so. Verified in a purpose-built core-only venv: 1028 passed, 4 skipped. With all extras: 1190 passed, 2 skipped. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MczR69sDwaWFDBwf5f2n1L * test: keep VectorStoreCreationError covered in a core-only install The previous fix skipped the whole module without the langchain extra, which fixed the test failure and created a coverage one: CI installs no extras, so the exception class itself went untested there and codecov flagged 3 uncovered lines. VectorStoreCreationError lives in fmp_data.exceptions precisely so that `except VectorStoreCreationError` works without the extra, so its own tests must run core-only too. Only the tests that call create_vector_store need langchain; they now carry @requires_langchain and import from fmp_data.lc inside the function body. Core-only: 3 pass, 6 skip in this module (was 0 pass, 9 skip). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MczR69sDwaWFDBwf5f2n1L * fix(lc): address review findings on period hints, search recall and test placement Three independent reviews of #150 found no Critical issues and confirmed the mutation claims in the PR body, but surfaced two defects and several gaps. Correctness: - The consolidated PERIOD_HINT advertised examples the endpoints reject. The catalog enforces three different valid_values sets under the one `period` parameter, and `examples` is rendered verbatim into the tool description while `EndpointParam.validate_value` rejects anything outside valid_values. The union told three company endpoints to send FY/Q1 (a regression this PR introduced) and six bulk endpoints to send annual/quarter (pre-existing). Split into PERIOD_HINT, PERIOD_WITH_FISCAL_HINT and FISCAL_PERIOD_HINT, one per value set; all nine violations are gone. - `search` shrank its own result count on a stale index. Deprecated entries persisted by an earlier release occupy top-k slots, so filtering after the fetch returned k-1 live endpoints in exactly the case the filtering exists for. It now widens the fetch while filtered entries displace live ones and truncates to k. Threshold drops do not trigger a refetch. - PAGE_HINT's main pattern was the widest of its four copies and matched inside a longer token ("webpage3" -> page 3); anchored. - `add_endpoints` returns the number indexed, so `create_new_store` stops logging the offered count (215) as if it were the stored one (212). - `VectorStoreCreationError.__str__` appends the skipped endpoints, following RateLimitError. `failures` was reachable only by destructuring. Test placement -- the same defect commit 1a46e6a fixed for #133: - The two cross-mechanism tests ran in no CI job at all: they need `mcp`, and `tests/unit/lc/` is skipped without `langchain`, which no mcp job installs. Moved to tests/unit/test_mcp.py, the only file the mcp-server job runs; verified passing in an mcp-only venv. - The four accidental-deprecation guards need no extra, so they move to a top-level tests/unit/test_deprecated_endpoint_flags.py and now run in the default no-extras matrix rather than only in the langchain job. New guard: test_hint_examples_are_within_valid_values walks all 215 registered endpoints and fails when a hint advertises a value its parameter rejects -- the class of defect above, previously invisible. Mutation-tested, as are the search widening and the add_endpoints count. Also: registry count corrected 216 -> 215 in the CHANGELOG (wrong at base too), error-contract floor raised from >100 to >=200 so a re-regression to #129's 168 cannot pass, one vacuous-if-empty assertion given a non-empty guard, and two "subsumed by" comments corrected to "deliberately narrowed" -- the anchored patterns match strictly less, which is the point. Verified: 1196 passed / 3 skipped with all extras, 1037 passed / 11 skipped core-only, ruff and mypy clean. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 * fix(mcp): one pure resolver, and a startable generated manifest (#149, #148) (#151) * fix(mcp): one pure resolver for bare tool keys (#149) The rule "a bare tool key resolves only when exactly one client claims it" was implemented three times: in tool_loader._resolve_tool_spec, in cli._classify_manifest_entries (fmp-mcp validate) and in the example-manifest guard test. Nothing kept them in agreement, and validate drifting from the loader turns a startup error into a trusted green check. Split resolution from announcement: the new pure resolve_tool_spec returns a Resolution (frozen dataclass + ResolutionStatus) describing what an entry resolves to and whether it is deprecated, ambiguous or unknown -- no warning, no log line, no raise. _warn_if_deprecated is now called only on the registration path, which is what stopped validate from reusing the resolver. validate and both example-manifest guards consume the pure function, so reimplementing the rule breaks a test. A full spec is now checked against the catalog like a bare key, so a typo fails at resolution rather than at _load_semantics. Co-Authored-By: Claude Opus 5 * fix(mcp): make `fmp-mcp generate` emit a startable manifest (#148) With no --tools filter, generate wrote all 223 catalog specs verbatim: three keys this release deprecates, plus both sides of two tool-name collisions, so registration failed with "Duplicate tool keys detected for MCP tool names: crypto_quotes, forex_quotes". The documented path handed users a file that could not start a server. DEPRECATED_TOOLS is now filtered out of the default spec list, and one side of each collision is excluded from the generated default set -- guidance alone (a header comment) cannot make the file register, so guidance and exclusion ship together. The header names every excluded spec, what was kept in its place, and FMP_MCP_TOOL_NAME_STYLE=spec for serving both sides; the kept side is whichever spec DEFAULT_TOOLS already serves. Colliding names are not auto-qualified: that changes the tool-name contract and needs its own discussion. An explicit --tools selection is never thinned; a collision inside it is reported on stderr and in the header instead. Acceptance test is behavioural: a no-filter generated manifest must register every tool it lists under the default name style, with DeprecationWarning raised as an error. Co-Authored-By: Claude Opus 5 * fix(mcp): explain deprecated drops, promote the resolver's input builder Review findings on #148/#149. `generate` dropped the three deprecated specs by set subtraction and appended no note, so the header explained the two collision losers and said nothing about the other three. A user diffing `fmp-mcp list` (223) against their manifest (218) saw two accounted-for absences and three unexplained ones -- in the feature whose whole point is that nothing disappears silently. The docstring, `docs/mcp/configurations.md` and the CHANGELOG all asserted otherwise. Deprecations now get their own header section and stdout line, each naming the replacement that ships in its place. They are listed apart from the collision drops because the remedy differs: a collision loser needs `FMP_MCP_TOOL_NAME_STYLE=spec`, a deprecated key needs a rename. "Included below" is checked against the post-collision list rather than asserted, so the note cannot lie if a replacement is ever itself dropped. The guard is exhaustive rather than by example: every spec in `catalog - manifest` must appear in the header, so a future exclusion rule cannot ship without its explanation. Also, from the same review: - `_build_key_to_spec` -> `build_key_to_spec`. It builds the only argument the new public `resolve_tool_spec` takes, and this PR is what first gives it an out-of-module caller, so leaving it private shipped a public function reachable only through a private name. - A stub guard for the loader, mirroring the one `validate` already had. The equivalence guard catches a loader that diverges; only stubbing catches one that reimplements the rule identically. - A guard on the default-yield path: an explicit `--tools` pick of the normally-dropped side wins, and the default it displaced is named. Every new guard mutation-tested. 1214 passed, 3 skipped; lint and mypy clean. Co-Authored-By: Claude Opus 5 (1M context) * fix(mcp): teach validate about name clashes, and tidy generate's edges Second review round on #148/#149. `fmp-mcp validate` could not see the one registration failure that is not a property of any single entry. #149 made the per-entry rule shared, and #148 taught `generate` to refuse a manifest whose tools would advertise the same name twice -- but `validate` learned neither, so the very file `generate` warned about validated without a word, then failed at server start with `Duplicate tool keys detected`. Entries are resolved before being compared: `["profile", "company.profile"]` is one tool listed twice and comparing the strings as written sees nothing. The two causes are reported apart because the advice differs -- two different tools claiming one name are servable with `FMP_MCP_TOOL_NAME_STYLE=spec`, while one tool named twice is refused under either style and the only fix is to drop one. The mutation that collapses the two cases hands out the misleading advice, and fails. Not fixed here: `validate` still exits 0 on all of this. That is pre-existing on dev and changes the CLI's contract for anyone scripting it, so it is #161. Also from the review: - A deprecated key named explicitly in `--tools` is kept, as an explicit ask should be, but now reported. Silently, `--tools` would be the only CLI path that puts a deprecated key in a manifest without saying so. - `_add_defaults` skips deprecated specs. Dead defence today since the tables are disjoint, but it runs after `_startable_catalog` stripped them, so a future deprecated default would be silently reinstated into the manifest this release just cleaned. Guarded by injecting the overlap. - Generated manifests end with a newline; an unknown `--tools` entry points at `fmp-mcp list`; CHANGELOG:55 no longer names the pre-rename `_build_key_to_spec` two lines away from announcing the rename. Five new guards, all mutation-tested. 1221 passed, 3 skipped; lint and mypy clean. Co-Authored-By: Claude Opus 5 (1M context) * fix(mcp): believe FMP_MCP_TOOL_NAME_STYLE in validate's clash check Third review round, on code added in the second. The clash warning computed advertised names under the default `key` style unconditionally, so `FMP_MCP_TOOL_NAME_STYLE=spec fmp-mcp validate` on a colliding manifest printed "Set FMP_MCP_TOOL_NAME_STYLE=spec" at a user who had already set it. The worst shape of false positive: it lands only on the person who took the earlier advice, and the advice it repeats is the thing they did. Names now come from the effective style. Under `spec` the advertised name is the whole spec, so the two `crypto_quotes` tools are genuinely distinct and draw nothing. The duplicate case survives the change, as it must -- `["profile", "company.profile"]` is one tool listed twice under any style -- and that pair is what the suppression must not swallow, so it is pinned by its own test. The duplicate message also claimed registration "refuses a repeated tool name under either name style". Not true: `_validate_tool_names` returns early for any non-key style, so under `spec` the loader accepts the manifest and the second registration silently shadows the first. The message no longer promises a refusal it cannot deliver; the remedy was right and is unchanged. The loader gap is #162. `validate`'s unknown-tool message now points at `fmp-mcp list`, matching what `generate` gained last round -- it was the same dead end. Filed rather than fixed: #162 (loader skips duplicate detection under spec style), #163 (`list` table truncates specs, tree shows methods, so neither is copy-pasteable), #164 (`generate` honours one of the two deprecation mechanisms). 1223 passed, 3 skipped; lint and mypy clean; the name-style mutation is caught. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 * fix: remove latent datetime shadowing, guard arg models against their endpoints (#142, #143, #141) (#154) * fix(models): remove 23 latent datetime-shadowing sites, guard against more (#142) `from datetime import date` plus `date: date` makes the whole module un-importable under pydantic 2.13, not just the field. An AST scan found 23 more class attributes named `date` in modules doing `from datetime import date`. None were broken -- each is annotated `datetime` or `str`, a different name -- but narrowing any one of them to the semantically-correct `date` reproduces #139 immediately, and the error blames the annotation clash rather than the edit that caused it. Five modules now use the `from datetime import date as dt_date` idiom already established in technical/schema.py and investment/schema.py, with their 20 bare `date` annotations updated to match. No field name, alias, annotation or wire behaviour changes -- only the name the annotation is written under. tests/unit/test_datetime_shadowing.py is the guard: it parses every module in the package and rejects any class attribute whose name equals a name that module binds from `datetime`, naming file:line. It handles `as` aliases (which is why aliasing is the fix), plain `import datetime`, and nested classes, and carries floors on modules and attributes scanned so it cannot pass vacuously. test_imports.py catches the explosion after someone steps on the landmine; this removes the landmine. Co-Authored-By: Claude Opus 5 * fix(investment): stop ETFHoldingsArgs requiring an optional parameter (#143) ETF_HOLDINGS declares `date` optional; ETFHoldingsArgs declared it required. A caller satisfying the endpoint contract was rejected by the tool schema before a request was ever built -- the arg model silently removed a capability the endpoint has. The endpoint is the correct side. Probed against the live API on 2026-08-08, etf/holdings?symbol=SPY returns 505 rows with and without `date`. The field becomes `dt_date | None = None`. tests/unit/test_arg_model_consistency.py guards the class: no arg-model field may be required where its endpoint declares the param optional. It scans every endpoint carrying an arg_model, so newly-wired ones are covered automatically, and checks the two investment holdings models explicitly because nothing wires them yet (#141). The reverse direction is deliberately not flagged -- an optional field always has a default, so it cannot under-supply a mandatory param. MUTUAL_FUND_HOLDINGS is left untouched. Its `date` is mandatory and ParamType.STRING, disagreeing with ETF_HOLDINGS on both counts, but neither can be verified while the path 404s for every request. Its arg model stays required to match, and both sides now carry comments pointing at #152, where the question of where that endpoint should point belongs. Co-Authored-By: Claude Opus 5 * test(schema): guard every Args model against its endpoint (#141, #143) #141's second concern -- "anything that rots in them rots silently" -- had no guard. test_imports.py catches a module that will not import; nothing caught a model whose parameters had drifted from its endpoint's. The gap is not confined to the three domains #141 names. `arg_model` is read by nothing anywhere in the package, so all nine schema.py modules are equally unchecked. This guard covers all of them: 81 (endpoint, model) pairs, 52 distinct models, 148 fields. Three rules, decreasing strictness: 1. A field required where the endpoint's param is optional is never tolerated. That is #143's defect (ETFHoldingsArgs.date), so the fix in 32b4e3f now has a regression guard attached. 2. A field optional where the endpoint's param is mandatory is allowed only when some default exists on either side, so a complete request still reaches the wire. 3. Every field must name a real parameter of its endpoint. Rule 3 found 9 pre-existing drift sites, none introduced here. They are frozen in _KNOWN_PARAM_DRIFT with a reason each: the set cannot grow, and cannot go stale either -- a fixed entry must be deleted or the test fails. The largest is TechnicalIndicatorArgs, whose start_date/end_date/period/ interval correspond to params actually named from/to/periodLength/ timeframe with no alias bridging them, across 9 indicator endpoints. Pairing uses only declarations already in the repo: Endpoint.arg_model where set, plus a hand-verified table for the three domains that set it nowhere. Models with no single unambiguous endpoint are skipped and counted (56, asserted as a ceiling), not guessed at. Package walks catch BaseException, not Exception -- fmp_data/mcp/__main__ raises SystemExit without its extra. All four rules were mutation-tested, and the suite verified core-only as well as with all extras. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 * fix: reconcile required flags with param lists, guard the API docs (#144, #146) (#155) * fix: reconcile required flags with param lists, guard the API docs (#144, #146) #144: 14 parameters sat in optional_params while declaring required=True. validate_params skips None for non-mandatory params before validate_value consults the flag, so omitting one always worked -- but validate_value is public, and a direct validate_value(None) raised 'Missing required parameter' for a parameter the endpoint treats as optional. The flags now follow list membership, with a guard so they cannot drift apart again. Verified against the live API that these really are optional rather than mislisted: news/stock returns rows with and without a symbol. #146: docs/api/endpoints.md claimed 47 Market Intelligence endpoints; the code and the document's own table both had 46. docs/mcp/tools.md has been guarded for a while, which is why #130's catalog change left no stale number; endpoints.md and docs/mcp/configurations.md had no equivalent. Now per-client counts and per-section table rows are asserted against the code, and every examples/... path the configuration guide names must exist. Both guards mutation-tested. Core-only: 1026 passed, 5 skipped. With extras: 1168 passed, 4 skipped. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MczR69sDwaWFDBwf5f2n1L * fix(docs): assert endpoints.md against the code row for row (#146) The fix for #146 corrected the table of contents from 47 Market Intelligence endpoints to 46 and guarded the counts. It left the document still contradicting itself: `## Market Intelligence` carries its own `### 47 endpoints` heading, a third independent copy of the number that the guard never looked at. The module docstring already claimed "row-set equality, not just counts, because a rename miscounts as zero". The implementation only counted. Implementing what it claimed found drift no count could see: doc `search_name` code `search-name` doc `crypto_symbol_news` code `crypto_news_symbol` doc `forex_symbol_news` code `forex_news_symbol` Every count in those sections matched, so nothing noticed. Paths are compared too, and that is the one a reader could act on: four rows documented the bare `/stable/historical-price-eod`, the exact path CLAUDE.md forbids because it needs a variant suffix. The code requests `historical-price-eod/full`. `search-name` was documented as `/stable/search_name`, which does not resolve either. Every correction landed in the document, not the code. `Endpoint.name` is the cache-key prefix and the argument to `_get_cache_ttl`, so renaming `search-name` to match its documentation would have quietly changed cache behaviour. That naming oddity is filed separately. Modules that fall out of the package walk are now named in any failure message -- `SKIPPED_MODULES` was populated and never read, so a mismatch found by a partial scan read exactly like one found by a complete scan. Row parsing ignores fenced code blocks and carries a >= 250 row floor, so the guard cannot pass by matching nothing. Five mutations checked: a wrong TOC count, a wrong section heading, a renamed row, a changed path and a deleted row each fail, naming the specific mismatch. core-only: 1044 passed, 11 skipped all extras: 1229 passed, 4 skipped Co-Authored-By: Claude Opus 5 (1M context) * fix(docs): close three vacuity holes in the endpoints.md guard (#146) Review findings on the previous commit. Two of the three are holes the guard opened itself. 1. The document was only ever proved to be a subset of the code. Every assertion iterates over the document's sections, so a client with an `endpoints` module and no section has nothing to iterate over and all the guards stay green -- a brand-new undocumented client was invisible, which is the exact drift #146 is about. Now checked in the code -> doc direction as well. (A *renamed* section was already caught: its label stops resolving to a module and the TOC check reports it. Verified rather than assumed.) 2. Row-set equality lost duplicate detection. `dict(_doc_rows(section))` collapses a row listed twice before anything counts it, so set equality passes and `checked` does not move. The row-count assertion this replaced would have caught it, so the rewrite was a regression on that one case. Rows are counted as a list and duplicates reported. 3. `test_configurations_doc_paths_exist` matched only backticked paths, which is one of the four the document names -- the directory. The three that matter are the concrete manifests inside `export FMP_MCP_MANIFEST=...` and `create_app(...)` snippets, which are fenced and unbackticked. So the guard validated the one path nobody copy-pastes and none of the ones they do, while reading as thorough. Matched bare now, floor raised from >0 to >=4. Also corrected, all made false or left stale by this PR: - `create_parameter_fields`' docstring still justified ignoring `EndpointParam.required` with "13 params sit in optional_params with required=True". That is 0 as of #144 (and was 14, not 13). It now records which half is fixed and which is still live, since this is the text a maintainer reads when deciding whether the flag can be trusted. - `## Unreleased` had two `### Fixed` blocks; this PR added the second one. Merged. - The `BaseException` comment cited `mcp/__main__.py`, which the `.endpoints` filter excludes and which can never reach that import. More usefully, the failure it gestured at is raised by `walk_packages` from the `for` header, outside the `try` -- so an `onerror` callback is needed as well, and both now record what fell out. - Dropped the no-op `_LABEL_TO_CLIENT["sec"] = "sec"`. Three new mutations, each caught by the intended test: a duplicated row, an undocumented client, and a manifest path that does not exist. core-only: 1050 passed, 11 skipped all extras: 1235 passed, 4 skipped Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) * fix: retire 28 endpoints FMP no longer serves, correct 6 signatures, add a live signature suite (#169) * fix: correct endpoint signatures that no request could satisfy All five defects were measured against the live stable API, not inferred. Path templates modelled `symbol` as a path segment where the API wants a query parameter, so every call 404'd: alternative.CRYPTO_INTRADAY historical-chart/{interval}/{symbol} alternative.COMMODITY_INTRADAY historical-chart/{interval}/{symbol} Both now match the already-correct alternative.FOREX_INTRADAY shape, `historical-chart/{interval}` with `symbol` in the query string. Verified: 5min?symbol=BTCUSD -> 2826 rows, ?symbol=GCUSD -> 2416. investment.MUTUAL_FUND_HOLDINGS declared `symbol` as location=PATH while its path carries no `{symbol}`. build_url substitutes only placeholders and get_query_params sends only QUERY params, so the symbol a caller passed was substituted nowhere and sent nowhere -- dropped in silence. Now QUERY. sec.SEC_FILINGS_SEARCH_{SYMBOL,CIK,FORM_TYPE} declared `from`/`to` optional; the API answers 400 "Invalid or missing query parameter - from" without them. Moved to mandatory_params. No client method changes: all three already defaulted the window to the last 30 days, so they were never able to hit the 400 -- the declaration was simply lying about the contract. sec.INDUSTRY_CLASSIFICATION_SEARCH needs at least one of cik/sicCode/symbol (400 otherwise) but each is individually optional, which mandatory_params cannot express. search_industry_classification already enforces it; the description now says so rather than leaving the constraint undocumented. market.CIK_SEARCH was reported as sending the wrong parameter name. It is not: the param already carries alias="cik" and `wire_key = param.alias or param.name`, so `cik=` was always what went out. The real defect was the type -- ParamType.STRING sent `320193` where FMP matches a fixed-width 10-digit CIK, the exact case ParamType.CIK exists to handle and which sec.SEC_FILINGS_SEARCH_CIK already used. Retyped, and the description and both client docstrings now say a CIK is required, since the param is named `query` and a caller reasonably read that as free text (a name returns 400). Renaming the param to `cik` would change the public search_by_cik signature and the shared BaseSearchArg schema, so it is left alone deliberately. Adds a unit-level guard for the path-template class. The existing e2e check only catches a placeholder with no matching param, which would have missed both bugs above -- CRYPTO_INTRADAY did declare `symbol`, just in the wrong location. The new test asserts PATH params and `{placeholder}`s correspond one-to-one in both directions, across all 275 declarations, with no API key. Co-Authored-By: Claude Opus 5 * fix: deprecate endpoints FMP no longer serves, with migration pointers Eight endpoints 404 on the stable API with no faithful replacement. Each now warns through the repo's existing @deprecated helper, returns empty without issuing the request, and names the closest live alternative -- a request that can only earn a 404 should not cost the caller a rate-limit slot. Repointed to a live library method where one exists: company.CORE_INFORMATION -> get_profile company.COMPANY_OUTLOOK -> get_profile company.PRICE_TARGET -> get_price_target_summary/_consensus company.ANALYST_RECOMMENDATIONS -> intelligence.get_grades_consensus company.UPGRADES_DOWNGRADES -> intelligence.get_grades company.UPGRADES_DOWNGRADES_CONSENSUS -> intelligence.get_grades_consensus None is a drop-in and each docstring says exactly how it differs: profile is flatter and drops the registration fields, price-target-summary is an aggregate rather than the per-analyst series, grades-consensus is one current tally rather than a monthly series. Repointing the paths silently would have handed callers a 200 with the wrong shape, which is worse than the 404. institutional.ASSET_ALLOCATION and institutional.FAIL_TO_DELIVER have no replacement at all -- hyphen, slash and plural variants all 404. The docstrings say so and name the only remaining sources (per-filer 13F holdings, aggregated by hand; the SEC's own fails-to-deliver files). COMPANY_OUTLOOK was already orphaned: declared, but reachable from no client method and no mapping. Its description is corrected rather than left to advertise a dead path to the LangChain and MCP surfaces. Endpoint descriptions for all eight now open with "DEPRECATED and non-functional ... Do not select it", matching the wording intelligence already used for stock_news_sentiments, so semantic search stops offering them. Six of the fourteen endpoints in scope needed no change -- the three social sentiment methods are already @removed, and earnings_confirmed, earnings_surprises and stock_news_sentiments are already deprecated. get_stock_news_sentiments open-coded the warning with a function-local `import warnings`; it now uses the @deprecated decorator like its neighbours. Tests: six asserted the old behaviour, i.e. that a request which can only 404 was still issued. They now assert the deprecation contract instead -- warns, returns empty, and does NOT call the API. The two unwrap-a-list / unwrap-an-object cases for upgrades_downgrades_consensus are dropped as unreachable: there is no response left to unwrap. Coverage added for get_analyst_recommendations and get_upgrades_downgrades, which had none. Co-Authored-By: Claude Opus 5 * test(e2e): probe endpoints through the library, not a reimplementation The live-signature suite built its own URLs, which made it disagree with the client it was meant to police and produced false failures in three ways: - it never substituted `{placeholder}`s, so every path-templated endpoint was requested with a literal `{interval}` in the URL and reported as a 404; - it keyed parameters off `param.name`, ignoring `alias`. The wire key is `param.alias or param.name`, so `CIK_SEARCH` was probed as `query=Apple` where the library sends `cik=`, and the 400 read as a declaration bug; - it skipped `ParamType` conversion, so a CIK went out unpadded. Probing now goes through `validate_params` -> `build_url` -> `get_query_params`, exactly as `BaseClient._execute_request` does, so a failure here is a real failure for callers. `_sample` prefers `valid_values` over the SAMPLES table, which is a hard constraint rather than a preference: `period` sampled as "annual" was rejected before any request for the endpoints that accept only Q1..Q4/FY, aborting the whole 404 sweep on the first one. Samples are looked up by wire name so an aliased param gets a value of the right kind. Deprecated endpoints are excluded from the 404 assertion. A path FMP has withdrawn still 404s after we deprecate it -- deprecating is the acknowledgement that it is dead, not a repair -- so probing it would make the suite fail forever for endpoints already handled. The set is derived rather than hand-written: `@deprecated`/`@removed` now stamp `__fmp_deprecated__` on the wrapper, and each module's `*_ENDPOINT_MAP` ties a method to its endpoint, so a method that stops being deprecated rejoins the assertion automatically. Orphaned endpoints, which have no method to carry a marker, fall back to the `DEPRECATED and non-functional` description convention -- COMPANY_OUTLOOK is declared but wired to nothing. 14 endpoints detected. Two assertions were also asking questions the API cannot answer: - omitting a mandatory param says nothing when that param IS the URL, so path-located params are skipped rather than counted as evidence; - `INDUSTRY_CLASSIFICATION_SEARCH` needs one of three individually-optional params. Every param is correctly optional and a bare request correctly 400s; that is the contract, not a bug, so it is exempted by name. This file was untracked, so pre-commit had never linted or typechecked it; committing it surfaced five findings, all addressed rather than ignored. Result: `test_optional_params_are_really_optional` and `test_declared_path_templates_are_substitutable` now pass. The 404 sweep is down from 28 to 15, all 15 being the dead-path group still awaiting a decision on repoint-vs-deprecate. Co-Authored-By: Claude Opus 5 * fix: deprecate 15 dead paths whose replacement the library already ships These were briefed as repoints. They are not repointable: for every one, the "new" path is already declared as a separate, live endpoint in this package with a working client method. alternative.CRYPTO_QUOTES -> batch.BATCH_CRYPTO_QUOTES already serves batch-crypto-quotes company.HISTORICAL_SHARE_FLOAT -> company.SHARE_FLOAT already serves shares-float investment.MUTUAL_FUND_HOLDINGS -> investment.ETF_HOLDINGS already serves etf/holdings ... and 12 more in the same shape. Repointing would have produced 15 duplicate (path, params) pairs, which is exactly the duplication #130 and #136 spent two PRs removing. Nine would also have failed validation against their own response_model -- batch-*-quotes omit the required `timestamp`, aftermarket-quote has no `price`/`session`, ratings-historical lacks three required fields -- converting a clean 404 into a runtime parse error, which is strictly worse. So each is deprecated instead, warning with the name of the method that already works and stating how the payload differs. What FMP actually did was move these paths; the library migrated and left the old declarations behind as dead weight. Three have no replacement at all. mutual_fund_by_name: a dozen path variants probed, all 404; search-name is a generic symbol search, not holdings. tradable_list: available-traded/list, symbol-list, tradable-symbol-list and symbol/all all 404 too -- stock_list/etf_list/actively_trading_list are partial substitutes covering *different universes*, so naming one as "the" replacement would be wrong. pre_post_market: the market-wide shape no longer exists, only per-symbol aftermarket-quote, so there is no signature-compatible successor to offer. etf_holder and mutual_fund_holder declared the *same* dead path as each other, so only one successor is needed. Also stops a default MCP server advertising tools that cannot work. DEFAULT_TOOLS goes 156 -> 137: 19 keys named an endpoint that 404s, so they could only ever answer empty while still competing for an LLM's attention against the tool that works. The catalog stays at 223 -- all 19 remain loadable by explicit manifest until 3.0, now behind a DeprecationWarning. Their successors live in a new WITHDRAWN_TOOLS map rather than in DEPRECATED_TOOLS, because the two mean different things. DEPRECATED_TOOLS is two keys for one callable and its warning says the replacement is a drop-in; every WITHDRAWN_TOOLS successor is a different endpoint with a different payload, and six have none. Merging them would have made that drop-in promise false -- the existing test asserting it caught the attempt, which is why the maps are separate. The withdrawn warning has its own wording accordingly. Guards added: the two maps must stay disjoint, no withdrawn key may sit in DEFAULT_TOOLS, every named successor must resolve and must not itself be withdrawn, and no withdrawn pair may share a (client, method) -- compared as a pair because the bare method name is ambiguous across clients, which is precisely how alternative.get_crypto_quotes and batch.get_crypto_quotes differ. Tests updated where they asserted a request that could only 404 was still issued; docs/mcp/tools.md counts follow the catalog, as its guard requires. Co-Authored-By: Claude Opus 5 * test(e2e): assert the deprecation exemption stays earned The 404 sweep skips deprecated endpoints, which is only honest while they really are dead. Left alone, that exemption is a place for a mistake to hide: deprecate something prematurely, or have FMP quietly revive a path, and the suite goes quiet either way. The three states are now asserted separately: dead + deprecated -> passes dead + not deprecated -> fails test_no_endpoint_path_returns_404 alive + deprecated -> fails test_deprecated_endpoints_are_really_dead The last is the one that was missing, and it is worth having: an endpoint answering with data while the library tells users to migrate off it is a docstring actively giving bad advice. Currently passing -- all 29 deprecated endpoints return no data. Co-Authored-By: Claude Opus 5 * test(e2e): stop inferring "param is optional" from a row count The assertion omitted the single mandatory param and read 200-with-rows as proof the param was not required. It flagged 14 endpoints and every one was wrong, because FMP defaults to Apple when `symbol` is missing and AAPL is this file's own sample value: key-executives?symbol=AAPL -> Suhasini Chandramouli, Greg Joswiak key-executives -> Suhasini Chandramouli, Greg Joswiak Identical payloads. "It answered without the parameter" meant "it defaulted to the same company we asked about", not "the parameter is ignored". Acting on that reading would have made `symbol` optional on 13 endpoints, after which omitting it hands the caller *a different company's data* -- considerably worse than the 400 the declaration currently prevents. The probe now supplies a non-default sentinel (MSFT), omits the param, and compares the two payloads. Different -> the API fell back to a default, so the param really does select the entity and is required. Identical -> the API ignored what we sent, which is the only thing "over-declared" actually looks like. Verified against the endpoints that were failing: with MSFT, key-executives returns different people and employee-count returns MSFT/0000789019 rather than AAPL/0000320193. SENTINELS is deliberately narrower than SAMPLES. A sentinel only works where the parameter means the same thing on every endpoint declaring it, because an *invalid* value also makes the API fall back -- reproducing the identical- payload confusion in a new place. `query` is a CUSIP for search-cusip, an ISIN for search-isin and a ticker for search-exchange-variants; `name` is a company for mergers-acquisitions-search but an indicator for economic-indicators. Confirmed live that search-cusip?query=Microsoft is byte-identical to omitting it. Those endpoints are skipped rather than guessed at: 115 of 128 candidates are probed, and a skip is honest where a guess is how the original got it wrong. The comparison is conservative in the safe direction. An endpoint whose payload moves between calls (live quotes) simply differs and goes unflagged -- under-reporting rather than crying wolf, which matters for an assertion that gets ignored the moment it fails spuriously, taking the four that do work with it. Both the module docstring and the test docstring record why row-count inference is wrong, alongside the same note for hand-built URLs, so neither is reintroduced. All six e2e tests now pass: no endpoint has a genuinely ignored mandatory param. Co-Authored-By: Claude Opus 5 * test(e2e): skip enum-constrained params in the sentinel probe validate_params rejects a sentinel outside valid_values and raises, so the sweep would abort rather than report. No endpoint hits this today; it is here because the failure mode is a crash, not a red assertion. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MczR69sDwaWFDBwf5f2n1L * docs: sync the two intraday paths corrected in this branch crypto_intraday and commodity_intraday moved from historical-chart/{interval}/{symbol} to historical-chart/{interval}; docs/api/endpoints.md still showed the old template. #155's row-for-row guard caught it, because it compares paths and not just row counts. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MczR69sDwaWFDBwf5f2n1L * fix(mcp): keep withdrawn endpoints out of generated manifests `_startable_catalog` filtered DEPRECATED_TOOLS but not WITHDRAWN_TOOLS, so `fmp-mcp generate` wrote all 19 dead specs into the manifest it produced. Every one names a path that 404s, so the generated server advertised 19 tools that could only ever answer empty -- the defect this branch removed from DEFAULT_TOOLS, still live on the generate path. Worse, one of them *won a collision against the tool that works*. `alternative.crypto_quotes` and `batch.crypto_quotes` both claim the bare key `crypto_quotes`, and `_preferred_spec` breaks the tie on DEFAULT_TOOLS membership. Once the dead spec was removed from DEFAULT_TOOLS earlier in this branch, neither side was a default, so the tie-break fell through to sorted order -- which puts `alternative` first. The generated manifest therefore kept `quotes/crypto` (404) and excluded `batch-crypto-quotes` (200, 4771 rows), and reported the working tool as the thing it had dropped. Withdrawn specs are now removed before the collision pass, which fixes both: nothing dead is emitted, and no dead spec can beat a live one. With one side of each pair gone, no collision reaches the manifest at all -- generate drops from 218 specs to 201 (223 catalog - 3 deprecated - 19 withdrawn), with zero collision exclusions where there were two. The three drop reasons are reported separately rather than merged, because the remedy differs and merging would lie: a withdrawn spec has no working endpoint behind it and six have no successor at all, a deprecated spec has a live replacement in the same manifest, and a collision loser is a working tool you can serve by switching FMP_MCP_TOOL_NAME_STYLE. The existing deprecation section promises "the replacement named beside each one is already included below, so no capability is lost" -- untrue for a withdrawal, so it gets its own section and its own wording, naming the nearest live tool where one exists and saying plainly that FMP publishes no replacement where it does not. Three tests asserted the old inverted behaviour -- that `batch.crypto_quotes` was the excluded side and `alternative.crypto_quotes` shipped. They now assert the corrected direction and record why the collision guidance is absent: it resolves a *surviving* ambiguity, and none survives while every ambiguous key has a withdrawn side. `test_ambiguous_bare_keys_are_exactly_the_documented_pair` remains the canary if that ever changes. Co-Authored-By: Claude Opus 5 * docs(changelog): record @removed as a deliberate 3.0 decision Every endpoint deprecated here carries allow_empty_on_404=True, so it returns [] today. @removed raises, so switching now would break any caller handling the empty list -- a breaking change in a minor release. @deprecated preserves the return contract while making the warning loud, matching the 2.6-deprecate / 3.0-remove cycle from #136 and #147. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MczR69sDwaWFDBwf5f2n1L * test: call every retired client method, not just inspect it codecov/patch failed at 75.25% against an 80% target. All 24 uncovered lines were bodies of the methods this PR retired -- they wrap endpoints FMP no longer serves, so nothing ever called them and the decorators were unverified. Arguments are bound from each signature rather than calling bare. A bare call fires the decorator and then dies on the missing argument *before* the body runs, so the warning assertion would pass while the stub `return []` -- the line that actually encodes the withdrawal -- stayed uncovered. Binding them makes this behavioural rather than a decorator-attribute check, and asserts the two things that matter: the method returns empty, and it does not reach the client. A retired endpoint that still issued a request would spend a caller's rate-limit slot to earn a 404. `@removed` is asserted against its own contract -- it raises RemovedEndpointError -- since it shares the `__fmp_deprecated__` stamp and a warning-only check would report those as broken. Nothing is swallowed: an unexpected exception fails the test and names the method. Patch coverage 75.25% -> 100%. Green in both environments: extras 1244 passed, nox core 1068 passed. Co-Authored-By: Claude Opus 5 (1M context) * fix: make withdrawal reachable in MCP and effective in LangChain Two review findings, same shape: WITHDRAWN_TOOLS was threaded through the surfaces that were top-of-mind and missed the two that read the older DEPRECATED concept. 1. The withdrawn branch of `_warn_if_deprecated` was unreachable. `_resolved` set ResolutionStatus.DEPRECATED only from DEPRECATED_TOOLS, and the two sets are disjoint by assertion, so every withdrawn spec resolved as plain RESOLVED and the call site's `if is_deprecated` gate was never true. A user pinning a withdrawn key in an explicit manifest got total silence. The unit test passed because it called the private helper directly and so could not see that nothing called it. ResolutionStatus.WITHDRAWN is now a real member, the registration path gates on `is_retired`, and `Resolution.successor` stays separate from `replacement` -- a deprecation is a rename with a drop-in, a withdrawal is a migration, and four of the 19 have no successor at all. Same root cause blinded `fmp-mcp validate`: it bucketed on `replacement is not None`, so it reported a manifest full of dead tools as healthy. It now buckets on status and reports withdrawn separately. validate and generate agreeing is the point of #149. 2. The LangChain vector store still indexed all 19. No mapping.py was touched, and the mitigation the CHANGELOG leaned on does not reach the index: registry.py embeds semantics, never the endpoint `description` where the "DEPRECATED ... Do not select it" wording was added. An agent asking for crypto quotes still got the dead endpoint and an empty success it could not distinguish from "no data matched". All 19 EndpointSemantics now carry deprecated=True, the mechanism #137 built, which `_is_deprecated` applies on the add, load and search paths at once. The guard derives its expected set from WITHDRAWN_TOOLS, so the next withdrawal cannot fix one surface and forget the other. Also: `generate --tools ` now warns; `_add_defaults` skips withdrawn; the async sweep asserts the empty return its sync twin did. `make test` no longer fires the live suite. It sources .env and promotes FMP_API_KEY to FMP_TEST_API_KEY, and testpaths is `tests`, so a populated .env meant ~700 live requests against a 250/day quota. tests/e2e is marked `live` and deselected by default; run with `-m live`. Two CHANGELOG claims were false as written and are now true. ruff clean, mypy clean (121 files), unit 1247 passed, nox tests-3.12(core) 1069 passed, e2e 6 deselected. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 * fix(helpers): @removed must preserve coroutine-ness, like @deprecated (#170) (#175) * fix(helpers): make @removed preserve coroutine-ness like @deprecated (#170) removed() always returned a plain sync wrapper, so the three async social-sentiment methods on AsyncMarketIntelligenceClient stopped being coroutine functions after decoration. Confirmed the impact is real, not theoretical: a RemovedEndpointError used to raise while a caller's argument list was still being built rather than at await time, so inside asyncio.gather(removed_endpoint(), other()) the sibling other() coroutine never started at all. removed() now mirrors deprecated()'s branch on inspect.iscoroutinefunction(func). test_deprecated_methods_warn.py's async sweep no longer needs inspect.unwrap() to classify these methods (the wrapper is honest now) and gains a floor asserting @removed async methods are actually reached. New parameterised + behavioural guards added to test_helpers.py, including basic @removed coverage it never had before. Co-Authored-By: Claude Sonnet 5 * test(helpers): assert the #170 async floor before the coarse sweep floor The removed_async_checked > 0 guard names the exact #170 regression, but the coarser 'checked > 25' floor tripped first in that scenario and reported a vague 'is the sweep working?'. Reordering lets the specific diagnosis fire. Verified by reverting helpers.py to 32e8815 in a scratch copy: the sweep now fails with the #170 message instead. Review follow-up on #175. The @deprecated async stacklevel attribution found in the same review is pre-existing and tracked separately in #177. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Sonnet 5 * fix(lc): harden the hint-duplication and deprecation-flag guards (#157, #159) (#173) * fix(lc): harden hint-duplication and deprecation-flag guards (#157, #159) - test_hint_consolidation.py: add a same-concept-different-name detector keyed on natural_names overlap + extraction_patterns substring overlap, excluding deliberate pattern-subset refinements (e.g. PERIOD_HINT -> PERIOD_WITH_FISCAL_HINT). Flags the pre-existing technical/mapping.py FROM_DATE_HINT/TO_DATE_HINT divergence from lc.hints.DATE_HINTS, tracked in KNOWN_SAME_CONCEPT_DIVERGENCES (with a staleness check) rather than folded in here, since that change affects nine indicator tools' embedding text and wants its own review (#157). - vector_store.py: type _is_deprecated's parameter as EndpointInfo instead of Any, and read info.semantics.deprecated directly instead of getattr(..., default=False), so a renamed/dropped field is a mypy error and an AttributeError instead of silently disabling filtering (#159). Co-Authored-By: Claude Sonnet 5 * test(lc): floor the #157 guard on comparisons, catch verbatim copies Review follow-up on #173. - MIN_SAME_CONCEPT_CANDIDATES pinned the *defect* count, so applying the fix #157 itself calls for -- folding technical/mapping.py's date hints onto the shared DATE_HINTS -- failed the guard with 'assert 0 >= 20' and a message blaming the detector. The obvious way out would have been to drop the floor to 0, reinstating the vacuous pass the floor exists to prevent. It now floors on pairs *compared* (5188 observed, floor 3000); detector regressions that keep comparing but stop matching are caught by the stale-allowlist assertion instead. Verified: the sanctioned fix now passes 9/9, and short-circuiting the detector still fails with an accurate message. - _is_extension exempted *equal* pattern sets, not just strict refinements, so a verbatim copy-paste under a different name with drifted examples and context_clues passed every test in the file -- the exact #135 defect class this guard advertises catching. Now a strict subset. Verified: the copy is caught, and the reviewed PERIOD_HINT split still does not trip. - CHANGELOG: the '#159 AttributeError at the first call' claim holds on the search/get_tools paths; on add_endpoints, _classify_endpoint's broad except turns it into a skipped endpoint plus an ERROR log. Both are loud, which is the point, but the wording now says which is which. - Documented the allowlist's or-semantics, the _hint_bindings scope limit (inline ParameterHint literals are invisible to both guards), and fixed two stale comments. Folding the technical date hints is now tracked in #179 rather than by a code comment alone. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Sonnet 5 * deprecate Endpoint.arg_model and the hand-written arg models (#153, #167) (#174) * deprecate Endpoint.arg_model and the hand-written arg models (#153, #167) `Endpoint.arg_model` was declared once, assigned 68 times across six domains, and read by nothing -- `grep -rn "\.arg_model"` finds no read site in the package. LangChain argument schemas are built dynamically in `fmp_data/lc/vector_store.py` from each endpoint's mandatory/optional params plus its parameter hints, so the 106 `*Args` classes in the nine `schema.py` modules never reached a tool either way. Removed now (internal wiring, no behaviour depends on it): the 68 `arg_model=` assignments and the schema imports that existed only to feed them. Keeping them was the actual harm -- `arg_model=ETFHoldingsArgs` reads as a contract, and #141 was filed on exactly that misreading. Deprecated, removed at 3.0 (public surface, stays importable): the `arg_model` field itself, which now warns when given a non-None value and stays silent on None or omission; and every `*Args` class, which warns on validation through a new no-field, no-config `DeprecatedArgModel` base. The runtime enums sharing those modules (`EconomicIndicatorType`, `IntradayTimeInterval`) are untouched. #167's drift is resolved by deletion rather than repair. `TechnicalIndicatorArgs` -- shared by all nine indicator endpoints -- has zero parameter-name overlap with its endpoint, and both `periodLength` and `timeframe` are mandatory with nothing in the model able to fill them. Repairing it means maintaining a second hand-written copy of parameter declarations that a live-API audit found wrong in ~33 places on the first copy, guarding a mechanism nothing reads. `tests/unit/test_arg_model_consistency.py` is replaced by `tests/unit/test_arg_model_deprecation.py`: the old guard paired models to endpoints via `Endpoint.arg_model`, which is now unset everywhere, and a frozen drift baseline for a layer scheduled for deletion earns nothing. Its two endpoint-side facts (#143, #152) move to the new file because they pin endpoint declarations, which outlive the models. The new file carries the 3.0 breadcrumb in the #136/#147 idiom. Co-Authored-By: Claude Opus 5 * fix(schema): warn on JSON-schema generation, widen the arg-model guard Review follow-up on #174. - DeprecatedArgModel now warns from __get_pydantic_json_schema__ as well as the validator. An argument model an external caller kept is most naturally handed to a tool/function-schema generator, which calls model_json_schema() and never validates -- that caller would otherwise have reached 3.0 without a single warning. Import stays silent: pydantic builds JSON schemas lazily, verified under -W error::DeprecationWarning and pinned by a new subprocess guard. - The deprecation guard filtered on 'Args' only, so BaseSearchArg and BaseExchangeArg -- public, deprecated, no in-tree subclass -- were covered by nothing; reverting either left the file green. Filter now takes both suffixes (116 models: 106 *Args + 10 *Arg) and the floor rises 100 -> 110. - CHANGELOG: the claim that deleting the 68 arg_model= assignments 'changes no behaviour' held only inside the package. An external caller reading SOME_ENDPOINT.arg_model now gets None silently, so it is stated under Changed rather than left under Deprecated. - Two test docstrings corrected: seven roots outside BaseArgModel, not four; and the BaseException catch is forward-looking, not a workaround for mcp/__main__.py, which this walk never imports. Mutation-verified: reverting BaseSearchArg's base, dropping the schema warning, and building a schema eagerly at import each fail a distinct guard. Docstring markers on the ~100 concrete classes tracked in #178. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 * fix(mcp): make validate, generate and the loader agree (#160, #161, #162, #163, #164) (#176) * fix(mcp): make validate, generate and the loader agree (#160, #161, #162, #163) Four entry points, one rule. `resolve_tool_spec` was introduced in #151 so `validate` and the loader could stop reimplementing "what does this manifest entry resolve to". Three call sites still had their own opinion. #160 -- `generate --tools ` did its own membership test against fully qualified specs only, so `profile` was reported "unknown" and dropped while `validate` and the loader both resolved it. It now consumes `resolve_tool_spec`, writes the resolved qualified spec, reports an ambiguous key as ambiguous rather than unknown, and collapses a repeated ask. #161 -- `validate` reported its findings and then printed "Manifest is valid" and exited 0 regardless, so CI went green on manifests that cannot start a server. It now returns False for exactly the four conditions `register_from_manifest` raises on. Deprecated and withdrawn stay reports. BREAKING for anyone with `fmp-mcp validate` in a pipeline. #162 -- `_validate_tool_names` returned immediately for any style but `key`, so the style that exists to fix name problems had no name checking. It now splits duplicates (one spec twice; fatal under every style) from collisions (two specs, one name; only possible under `key`), with advice that fits the style in effect. The grouping and the split moved to `tool_loader` and `validate`'s clash report consumes them instead of its own copy. #163 -- `list --format table` truncated the spec column, rendering `company.historical_price` and its replacement as the same string, with the deprecation marker inside the cut region; `--format tree` labelled leaves with method names, so the manifest entry never appeared. Spec column folds and gains a Deprecated column; tree leaves are the full spec. The guard that matters is not per-bug: a table of manifests -- the generated catalog plus every known-bad shape -- asserts under both name styles that validate's verdict is the exact inverse of whether registration raises, and that generate classifies every catalog spec and bare key as the resolver does. Co-Authored-By: Claude Opus 5 * docs(mcp): refresh two comments the coherence fix left stale `_report_manifest_findings` still said none of its findings was fatal, which stopped being true when validate gained an exit code (#161), and the duplicate branch still described the `spec`-style early return that #162 removed. Co-Authored-By: Claude Opus 5 * fix(mcp): retire the three untabled dead endpoints, fail an empty generate (#164) Two decisions, both recorded in the CHANGELOG. #164 -- `EndpointSemantics.deprecated` and `WITHDRAWN_TOOLS` record the same fact for two surfaces: FMP no longer serves this endpoint. The flag hid it from the LangChain vector store, the table hid it from MCP, and three specs carried the flag without being in the table -- so every generated manifest advertised `intelligence.earnings_confirmed`, `earnings_surprises` and `stock_news_sentiments`, each of which answers empty on every call. All three paths were probed live and 404, so they are withdrawn, not a filing question. They join WITHDRAWN_TOOLS with no successor (19 -> 22); a generated catalog manifest goes 201 -> 198; DEFAULT_TOOLS is unchanged at 137, so a default server is unaffected. Resolution is untouched -- an explicit manifest naming one still works until 3.0 -- so this is not a removal. `DEPRECATED_TOOLS` deliberately stays out of it. It records a different fact, an alias for a method that still works, and its warning promises a drop-in. Populating a table is not merging two mechanisms. The invariant is now enforced rather than remembered: a guard asserts `set(WITHDRAWN_TOOLS) == {specs flagged deprecated}`, deriving the flagged set by walking the mapping tables, with a floor so a broken walk cannot pass it against an empty set. `generate` exit code -- an explicit `--tools` selection where nothing resolved wrote `TOOLS = []` and exited 0, which is #161's defect one command over: an artifact that cannot start a server, reported as success. It now writes nothing, exits 1, and names each failed ask beside its reason (unknown vs ambiguous). The output file is left alone, since the manifest already there may well be the good one. One bad entry among good ones is still only a warning. `generate_manifest()` returns bool. Co-Authored-By: Claude Opus 5 * refactor(mcp): reuse _pluralise in the empty-selection report A hand-rolled 'tool'/'tools' conditional two functions below the helper that does exactly that is the same duplication this batch exists to remove. Co-Authored-By: Claude Opus 5 * docs(test): widen the coherence module docstring to the full batch It listed three entry points and three issues; the module now also guards #163's list formats and #164's two retirement records. Co-Authored-By: Claude Opus 5 * test(mcp): derive the flagged set from the catalog, not the mapping modules The invariant guard walked `_get_client_modules()` and read each `_ENDPOINTS_SEMANTICS` table. That happened to be correct -- discovery already skips `lc` and `mcp` -- but only by relying on an exclusion by name, which is a thin thing for the guard to stand on. `fmp_data.lc.mapping` is one of the 14 modules with a `mapping.py` and it re-exports nine of the domain tables as the *same objects*. A walk that derives the client from the module path therefore counts every flagged endpoint twice, the second time as `lc.`: 44 flagged instead of 22, and a 22-entry phantom gap on top of the real one. The natural fix for that is to special-case `lc` out, which would mask a real gap if another module ever re-exported. Taking the specs from `discover_all_tools()` sidesteps the choice: those are the MCP catalog by definition, which is what the invariant is about, and each carries the client/key that reaches its semantics object -- so no module path is parsed at all. A new test pins it: every flagged spec must be in the catalog, which the naive walk fails by naming its own phantoms. It also asserts the re-export is still real, so if `lc.mapping` ever stops re-exporting, the warning gets deleted rather than left behind as folklore. Co-Authored-By: Claude Opus 5 * docs(test): record this guard's deliberate lc.mapping dependency The phantom guard imports fmp_data.lc.mapping to assert the re-export is still real, which is what stops the warning above it outliving its cause. That import survives a core-only install only because the module carries semantics data and pulls in no langchain -- true today by accident, not by design. CI installs no extras and that gap has bitten this repo three times, so the dependency is now stated where someone editing lc.mapping would see the consequence. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MczR69sDwaWFDBwf5f2n1L * fix(mcp): make generate's exit code agree with validate in every shape Review follow-up on #176. Two shapes where generate reported success on a manifest validate rejects -- the exact incoherence this PR exists to remove: - A collision inside an explicit --tools selection was written and exited 0, while validate exited 1 on the same file, so 'generate && validate' failed on an artifact generate had just called a success. Now nothing is written and the exit is non-zero -- but gated on the *active* name style, not the fixed 'key' style _name_collisions reports under: 'spec' style advertises both sides at their full spec and registers the pair, so there the manifest is still written. Refusing unconditionally would have been the mirror-image bug. Neither side is ever thinned; the refusal names the escape hatch. - 'generate out.py --tools profil' exited 0 having written 137 tools. The emptiness check ran after _add_defaults, so a selection in which nothing the user asked for resolved was rescued by defaults they never asked for and the failures were discarded. The documented 'writes no file and exits non-zero' was therefore true only under --no-defaults, which is opt-in -- every existing guard passed include_defaults=False. Judged before defaults now, and guarded with include_defaults=True. Also: promoted _get_tool_name_style to tool_name_style (alias kept), since cli.py was reaching across for it; documented the list output change in docs/mcp/configurations.md, which the PR body claimed but the docs diff did not do; recorded the TOOLS=[] validate/generate asymmetry in the docs table; recorded the --strict decision from #161 rather than leaving it hanging; and pinned that the 80-column table *folds* rather than truncates -- the existing 'full spec' guard renders at COLUMNS=400, where rich never shrinks anything, so it could not tell the two apart. Mutation-verified: dropping the early emptiness check, making collisions always fatal, making them never fatal, and hard-truncating the spec column without an ellipsis each fail a distinct guard. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 * feat(lc): derive tool examples and Literal types from valid_values; fold technical date hints (#156, #179) (#181) * fix(lc): derive tool schema examples and Literal from valid_values (#156) ToolFactory.create_parameter_fields advertised a parameter's hand-written ParameterHint.examples regardless of what the endpoint's valid_values actually accepted, so the two could disagree and hand the LLM a value the client would reject. Examples are now derived from valid_values whenever declared, and the generated field is narrowed to a Literal of those values so the constraint reaches the schema, not just the client-side check. Adds a catalog-wide guard that a generated schema's examples are a subset of valid_values, plus a data-independent test that manufactures a drifted hint directly so the guard is proven to catch a regression even when every real hint in the catalog currently agrees with its valid_values. Co-Authored-By: Claude Sonnet 5 * fix(lc): fold technical date hints onto shared DATE_HINTS (#179) technical/mapping.py bound its own FROM_DATE_HINT/TO_DATE_HINT for the from/to parameters shared by all nine indicator tools, describing the same start/end-of-range concept as DATE_HINTS["start_date"]/["end_date"] under different names, with different natural_names, extraction_patterns, examples and context_clues -- the #157 same-concept-different-name gap, tracked as a deliberate exception in KNOWN_SAME_CONCEPT_DIVERGENCES until now. COMMON_PARAMS["from"]/["to"] now point at the shared hints directly; the two local ParameterHint constants are deleted, and the four baseline entries are removed from KNOWN_SAME_CONCEPT_DIVERGENCES rather than left to rot. See CHANGELOG for what shifts in the nine tools' tool descriptions and embedding text. Co-Authored-By: Claude Sonnet 5 * test(lc): pin #156 via create_parameter_fields path Strengthen the manufactured #156 guard to assemble the args model through ToolFactory.create_parameter_fields (production wiring), not only the helper methods. Clarify #150 vs #156 pipeline comments: schema agreement is true by construction under ToolFactory; #150 remains the embedding/hand-written hint content guard. --------- Co-authored-by: Claude Sonnet 5 * refactor(models): derive required from list membership; warn on unmatched ttl_overrides (#165, #171) (#183) * refactor(models): derive EndpointParam.required from list membership (#165) An endpoint declares its parameters in mandatory_params or optional_params, and every parameter also carried a `required` flag restating the same fact. Two representations of one thing can disagree, and #144 found 14 that did. #155 reconciled them and guarded the reconciliation; this removes the second representation so there is nothing left to reconcile. `EndpointParam.required` is now a read-only property that `Endpoint` stamps from the list the parameter sits in. The 544 `required=` declarations across the 13 endpoint modules are gone, and because the property has no setter the two cannot be pushed back into disagreement at runtime either. Requiredness still reaches `validate_value` — the one read site, public and only reachable by direct call — by the same expression it always used, `self.required`. The value now arrives from the endpoint rather than from the declaration, so `validate_value(None)` on a mandatory param raises exactly as before. Threading it in as an argument was the alternative and would have changed that public behaviour. The `required=` argument is deprecated, not removed: it keeps positional slot 4 so an existing positional call is not silently shifted one place, it warns when supplied, and it is still honoured for a parameter detached from any endpoint. A declaration contradicting its list gets its own warning naming the endpoint. 3.0 drops the slot, with a breadcrumb test to force it. Holding slot 4 costs `description` its no-default status, so test_every_param_has_a_description covers what mypy no longer can. A parameter name in both lists now raises — the only remaining route to being required and optional at once. Co-Authored-By: Claude Opus 5 (1M context) * fix(cache): warn when a ttl_overrides key names no endpoint (#166) `BaseClient._get_cache_ttl` reads `CacheConfig.ttl_overrides` with a plain `.get(endpoint_name, default)`, so a key matching no endpoint was not an error, not a warning and not visible — the override simply never applied. A typo, a name copied from a newer version, or a name the library has since changed all failed the same silent way, with request volume as the only symptom. The warning names each unmatched key and the endpoint it nearly matched, so `'serch-name'` points at `'search-name'`. It warns rather than raises and keeps the entry: config is carried across versions, and deleting it would add a second silent behaviour on top of the one being fixed. The catalogue is walked lazily and only when `ttl_overrides` is non-empty, so the check costs nothing for the config almost everyone has. It cannot be walked at import time — this module is reached from `fmp_data/__init__.py`, so importing the catalogue to define a config model would invert the dependency. An empty catalogue means "could not tell", not "everything is wrong". Found while establishing #166's blast radius. #166 itself stays open, deferred to 3.0: renaming `market.search-name` also orphans persisted entries in the file and redis backends, since `_build_cache_key` uses `Endpoint.name` as a literal key prefix. This makes the TTL half of that rename announced rather than silent. Co-Authored-By: Claude Opus 5 (1M context) * test: address the three follow-ups from #169's review (#171) 1. `tests/e2e/test_live_signatures.py` keyed its 404-sweep exemption by the bare endpoint attribute name. Zero cross-client attribute-name collisions exist across the 275 declarations today, so no verdict changes — but one collision would silently exempt a *live* endpoint that happened to share a name with a dead one, and the exemption would stop being earned, which is exactly what test_deprecated_endpoints_are_really_dead exists to guarantee. `name_by_endpoint` already carried the client, so the set is now keyed by `(client, attr)` at no cost. mypy holds the keying — the set is typed `set[tuple[str, str]]`, so a revert to bare names fails the type check — and a new no-network test asserts every exempted pair resolves to a real declaration. 2. Nothing asserted that a *withdrawn* header line names its remedy. The deprecated side had test_deprecated_drops_name_their_replacement; test_every_dropped_spec_is_named_in_the_header proves only that a withdrawn spec appears, not that the remedy is attached to it. Since the argument for keeping the two categories separate is that the remedy differs, the remedy is the part worth asserting. test_withdrawn_drops_name_their_remedy checks both shapes: a successor plus its location, and "FMP publishes no replacement" for the seven that have none, with a floor so the no-successor branch cannot go vacuous. 3. test_generated_manifest_documents_the_excluded_side is renamed to ..._the_dropped_side. It stopped asserting anything about the excluded (collision-loser) category and now asserts the withdrawn one, so the old name misled anyone scanning for collision coverage. 4. CHANGELOG: the #169 entries' counts are correct but measured at different layers, and a reader will try to reconcile them and fail. One note names the layer each number counts and says the two are not subsets of one another. Also corrects "six have no successor at all" to four — the figure at #169, before #164 added three more. Co-Authored-By: Claude Opus 5 (1M context) * fix(cache): correct ttl_overrides key examples and warning text Use Endpoint.name (e.g. "quote") not method names in TTL override examples, point unmatched-key warnings at docs/api/endpoints.md rather than fmp-mcp list, and pin positional slot-4 required BC. --------- Co-authored-by: Claude Opus 5 (1M context) * fix: attribute async deprecation warnings to the caller; surface retirement in docs and list (#177, #178, #158, #180) (#182) * fix(helpers): attribute async deprecation warnings to the caller (#177) `@deprecated`'s async branch called `warnings.warn(..., stacklevel=2)` from inside the coroutine body. A coroutine body does not run in its caller's stack -- it runs wherever the event loop resumed it -- so `stacklevel=2` resolved to CPython's event loop. Measured on the same call site: the sync path reported the caller's file, the async path `asyncio/events.py:88`. The location is now looked up rather than counted. `_first_caller_frame` walks outward to the first frame that is neither `fmp_data.helpers` nor `asyncio`, and `_warn_at_caller` reports it via `warnings.warn_explicit`, passing the caller's module and `__warningregistry__` so `module=` filters match and the "default" action still dedups per location. The decorator's public shape is unchanged: the async branch is still an `async def`, so `inspect.iscoroutinefunction` stays true. Warning eagerly from a sync wrapper would have fixed attribution by reintroducing the lie #170 removed. The sync path now shares the same mechanism so the two cannot drift apart. Async generator functions get an async generator wrapper in both decorators. `iscoroutinefunction` is False for `async def ... yield`, so both fell through to the plain `def` wrapper and fired on generator *creation* rather than iteration -- #170's defect in a third shape. No async generators exist in `fmp_data` today; the guards define their own. The guards assert the recorded `filename` is the caller's file for both paths. Asserting only that a warning fired is what let this survive. Co-Authored-By: Claude Opus 5 * docs(schema): mark every concrete argument model deprecated (#178) #174 added `.. deprecated:: 2.7` to the eight root classes. Docstrings are not inherited for documentation purposes, so the ~116 concrete models users actually name -- `ETFHoldingsArgs`, `TechnicalIndicatorArgs`, `Form13FArgs` -- showed nothing in `help()`, in IDE hover or in Sphinx. The runtime warning only reaches someone who runs the code; a reader deciding whether to write the call, who can still avoid the migration entirely, was told nothing. `DeprecatedArgModel.__init_subclass__` appends the directive to any subclass that does not already state one, so current and future subclasses are covered from one place rather than by 116 hand-edited docstrings that drift. The class's own summary is kept and the note appended as its own paragraph after `inspect.cleandoc`; a class with no docstring gets a generated summary. Import stays silent -- this rewrites `__doc__` at class-creation time and emits nothing. Co-Authored-By: Claude Opus 5 * fix(mcp): label withdrawn endpoints in `fmp-mcp list` (#158) `list` derived deprecation from `DEPRECATED_TOOLS` alone -- the table of duplicate *names* for a live method -- and knew nothing about `EndpointSemantics.deprecated`. So `intelligence.stock_news_sentiments`, `intelligence.earnings_confirmed` and `intelligence.earnings_surprises` printed `deprecated: None`, indistinguishable from a live tool. Manifests are built by copying keys out of that listing, so this shipped users a tool that can only return an empty success -- what #137 removed on the LC side. The three retirement concepts stay three concepts, because each asks something different of the reader. A tool now carries `deprecated` (the replacement spec for a rename, or None -- unchanged meaning), `withdrawn` (FMP serves nothing) and `successor` (nearest live spec for a withdrawal, never a drop-in). `table`, `list` and `tree` render `[DEPRECATED -> spec]`, `[WITHDRAWN, nearest live tool spec]` or `[WITHDRAWN, no replacement]`; the table column is headed `Retirement`, since calling a withdrawal "deprecated" would state the one thing untrue of it. `withdrawn` is the union of `WITHDRAWN_TOOLS` and the semantics flag, not a mirror of one. They are held equal by #164's guard and agree exactly today (22 each), but reading a single table would look correct now and let drift decide the labelling later; a guard empties `WITHDRAWN_TOOLS` and asserts the labels survive. Labelling only. Every key still resolves, registers and serves. Co-Authored-By: Claude Opus 5 * test(mcp): skip the fold guard when `rich` is absent Pre-existing on `dev`, unrelated to the three issues in this branch, but it fails the no-extras CI shape: `test_the_spec_column_is_never_ellipsised_on_a_ default_terminal` reassembles the box-drawing columns of `_print_rich_table`. Without the `rich` extra `print_tools_table` falls through to `_print_simple_table`, which pads rather than truncating, the reassembled column comes out empty and the test fails for the wrong reason. Co-Authored-By: Claude Opus 5 * docs(changelog): record #177, #178 and #158 under Unreleased Co-Authored-By: Claude Opus 5 * fix(helpers): skip uvloop frames in deprecation attribution Walk past uvloop / uvloop.* (and asyncio) when blaming DeprecationWarnings, so uvicorn/FastAPI stacks land on user code. Cover with a pure unit test that injects fake frames; no real uvloop dependency. Also correct the filterwarnings module= comment and record #180 in Unreleased. --------- Co-authored-by: Claude Opus 5 * fix(ci): soft-fail Claude Code Review when auth is missing (#184) (#185) The review job exited with is_error:true and zero cost whenever CLAUDE_CODE_OAUTH_TOKEN was empty or expired, painting every PR red without posting a review. Skip the job when the secret is unset, and continue-on-error so an expired token cannot fail the workflow. * fix(lc): dispatch tools through client methods, not bare request (#172) (#186) * fix(lc): dispatch tools through client methods, not bare request (#172) LangChain tools called client.request(endpoint, **kwargs), so method-level defaults and constraints never applied. SEC filings search methods default from/to to the last 30 days; tools that only had a symbol raised locally instead. Resolve client.. (the MCP path), map wire kwargs onto the method signature, and reclassify schema optionality from method defaults. Fall back to request when no sub-client is present. * fix(lc): fall back when method params cannot map from endpoint schema Add a compatibility gate so tools whose method signature cannot be fully mapped from endpoint params (e.g. Form 13F year/quarter vs report_date) keep client.request dispatch and the pre-#172 wire schema. Under method dispatch, omit unmapped endpoint params from the tool schema so LLMs are not forced to supply kwargs that never reach the API. Include trade_date among date aliases. * fix(lc): endpoint↔method coverage guard and tool-arg docs (#188) Addresses #188 isolated scope: catalogue-wide LC endpoint↔method coverage guard, tool-arg docs (wire vs method names), and request-fallback/dropped-mandatory allowlists. #166 remains deferred to 3.0. Remaining #188 work (shared LC/MCP binding, institutional year/quarter alignment) stays on the issue. * fix(institutional,lc): align 13F tools with the API's year/quarter shape (#188) (#190) * fix(institutional,lc): align 13F tools with the API's year/quarter shape (#188) The last two request-fallback tools in the LangChain catalogue existed because their client methods required a `report_date` the wire does not have. Probing the live API settles which side was wrong: /stable/institutional-ownership/extract?cik=0001067983 -> 400 Query Error: Invalid or missing query parameter - year `year` and `quarter` are mandatory on both `extract` and `symbol-positions-summary`, neither accepts a date, and the period-end `date` is a response field. `report_date` was a client-side invention. Add `get_form_13f_by_quarter(cik, year, quarter)` and `get_institutional_holdings_by_quarter(symbol, year, quarter)` (sync and async), and point the semantics `method_name` at them. The existing date-shaped methods keep their signatures and delegate, so no Python caller changes; the request/error-handling body moves onto the wire-shaped methods the tool layers dispatch through. Effects on the tool surfaces: * MCP `form_13f` / `institutional_holdings` keep their names and now advertise `cik`/`symbol` + `year` + `quarter` -- which is what their `parameter_hints` already declared, and stops an assistant having to invent a quarter-end date to ask for a quarter. * LangChain tools rename to `get_form_13f_by_quarter` / `get_institutional_holdings_by_quarter` (tool names come from `method_name`); their argument schemas are unchanged, since these already used the wire schema under request-fallback. `KNOWN_REQUEST_FALLBACK` in the #189 coverage guard is now empty, so every tool in the catalogue dispatches through a client method. The allowlist and its assertions stay -- an empty allowlist is the strongest form of the guard -- and a synthetic test keeps the fallback branch of `create_tool` covered now that no real endpoint exercises it. Also fixes `test_endpoint_group_organization`, which reimplemented one of the three rules in `resolve_semantics_for_endpoint` (strip `get_`) and so reported the new keys as missing while the real registry resolved them fine. It now calls the resolver. Verified: 1425 unit tests with extras, 1196 core-only, ruff and mypy clean, and both new methods checked against the live API. Co-Authored-By: Claude Opus 5 (1M context) * docs(institutional): label 13F tool breaks and pin holdings dispatch Review polish for #190: CHANGELOG now marks the MCP arg change and LangChain tool renames as BREAKING, documents the LangChain Form 13F empty-on-error envelope, fixes the stale request-fallback docstring (allowlist is empty), and adds a wire-shaped dispatch test for institutional holdings symmetric to form_13f. Co-Authored-By: Grok Build * fix(lc,mcp): one shared endpoint→method binding layer (#188) (#191) * fix(lc,mcp): one shared endpoint-to-method binding layer (#188) MCP and LangChain answer the same question -- given an endpoint and the semantics naming its client method, how do I call that method? -- and both answered it separately. `mcp.tool_loader._resolve_attr` walked the attribute chain; `lc.vector_store` kept the alias table, the coverage gate and the invoke-time kwargs mapping. #188 calls that drift risk. It was not hypothetical. The "which parameters of this method can receive a value" filter existed in four hand-written copies -- three in `vector_store`, one more in the catalogue guard -- and they disagreed: `partition_params_for_method` filtered only on the name `self`, while the dispatch gate also filtered on parameter kind. A method with `**kwargs` looked like it required a parameter named `kwargs` to one copy and not to the other. `fmp_data/tool_binding.py` is now the single implementation. It imports nothing but `inspect`, so it sits in the core package rather than under `lc/` or `mcp/` -- a shared layer that needed an optional extra installed would not be shared. * `bindable_params()` is the one definition of a fillable parameter. The surviving behaviour is the stricter one (`*args`/`**kwargs` excluded). * `uncovered_required_params()` names the required method params no wire field can fill; `method_dispatch_compatible()` is defined as its absence, so the two cannot disagree. * `resolve_attr()` (raises) and `resolve_client_method()` (returns None) are documented as two reporting styles over one walk -- MCP must refuse to start on a ghost method_name, LangChain must fall back to `client.request` on a client with no sub-clients. The catalogue guard imports the shared helpers instead of keeping private copies; a guard that reimplements the rule it guards can only check itself. Every previously importable name still is, including the private `_camel_to_snake`, `_ENDPOINT_TO_METHOD_ALIASES` and `mcp.tool_loader._resolve_attr`. No caller changes. `tests/unit/test_tool_binding.py` pins the contract and lives outside `tests/unit/lc/` so it runs in the core-only environment CI installs: 1213 core-only tests, up from 1196. Verified: 1442 unit tests with extras, 1213 core-only, ruff and mypy clean, and four mutations of the new module each killed by a named test. Co-Authored-By: Claude Opus 5 (1M context) * fix(tool_binding): share one resolve walk and pin re-exports Review polish for #191: implement resolve_client_method via resolve_attr so the lenient/strict pair cannot drift, restore SearchResult on the vector_store star-import surface, document the **kwargs filter fix under Fixed, and identity-pin the MCP/LangChain re-exports. Co-Authored-By: Grok Build --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Grok Build --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Grok Build * fix(institutional): narrow async Form 13F exception swallowing (#193) (#197) Async get_form_13f_by_quarter and get_form_13f_dates caught bare Exception, so programming bugs were logged as "no 13F data" and returned []. Align with the sync client: catch only (FMPError, ValidationError). Empty-on-API-error remains intentional; tests pin that non-API errors re-raise. Closes #193 Co-authored-by: Grok Build * fix(institutional): add remaining wire-shaped year/quarter methods (#192) (#198) Add *_by_quarter methods for the remaining institutional date wrappers (extract, analytics, holder performance/industry, symbol positions, industry performance). Date-shaped methods derive year/quarter and delegate; holder performance keeps year/quarter optional when report_date is omitted. Python API only — no mapping/MCP/LC changes. Closes #192 Co-authored-by: Grok Build * fix(lc,tool_binding): warn on misconfigured request-fallback; harden bindable_params (#194, #195) (#199) When resolve_dispatch_method returns None, log at tool creation: - DEBUG if the store client has no sub-client (expected bare BaseClient) - WARNING if the sub-client is present but the method is missing - WARNING if the method shape is incompatible, naming uncovered required params via uncovered_required_params - bindable_params excludes cls as well as self - required POSITIONAL_ONLY params count as uncovered (cannot fill via kwargs) - map_tool_kwargs_to_method documents last-wins for aliased names - VAR_KEYWORD stays excluded; catalogue methods pinned to have none Closes #194 Closes #195 Co-authored-by: Grok Build * release: cut 2.6.0, including the #166 endpoint rename (#200) Prepares the dev → main release. Three parts. **#166: `search-name` → `search_name`.** The last endpoint whose `Endpoint.name` used a hyphen, and the only one whose `name` equalled its `path`. `name` is now snake_case like the rest of the catalogue; `path` stays `search-name`, re-probed live at 200, because that is the real API path. The second oddity the issue raised, `fail_to_deliver`, is left alone on purpose and the reason recorded at the declaration: probed live, `fail_to_deliver`, `fail-to-deliver` and `fails-to-deliver` all 404 with a known-good control returning 200 in the same run, so there is no live variant to rename to. It dies with the endpoint in 3.0. The issue deferred this to 3.0 because `Endpoint.name` is the literal cache-key prefix and the `ttl_overrides` lookup key, so renaming it both orphans persisted `file`/`redis` entries and silently drops a user's TTL override. The second half is no longer silent -- the unmatched-override warning shipped earlier in this same release and now names the replacement: cache ttl_overrides names no known endpoint: 'search-name' (closest endpoint: 'search_name') Verified end to end: cache key prefix is `search_name:…`, the override applies under the new key and falls back to the default under the old one, and the endpoint still returns live data through `market.search_company`. The orphaned-cache half remains and is stated plainly in the CHANGELOG; it is self-healing (one extra call per distinct query on upgrade). **Version markers normalised.** The tree disagreed with itself about the next version: 12 sites said `.. deprecated:: 2.7` while the CHANGELOG and `docs/superpowers/specs/…-design.md` said the next release is 2.6.0. All of it is unreleased and ships together, so it cannot be deprecated in two different versions. Normalised to 2.6, tests included. **CHANGELOG cut as 2.6.0**, following the 2.5.0 precedent: fresh empty `Unreleased`, a summary with a table of the four breaking changes and their migrations, and the duplicate Added/Changed/Fixed/Deprecated headings that accumulated across ~12 PRs consolidated into one set in Keep-a-Changelog order. Entry count asserted unchanged at 64 -- nothing dropped or reworded, beyond correcting two entries that still said #166 was deferred. Version math confirmed: `git describe` on main is v2.5.0, so a `release:minor` label produces 2.6.0. Verified: 1479 unit tests with extras, 1243 core-only, ruff and mypy clean, wheel and sdist build. Co-authored-by: Claude Opus 5 (1M context) * fix: warn that holder-performance-summary ignores year/quarter; report a real __version__ Two fixes found reviewing #201 before merge. **`holder-performance-summary` accepts year/quarter and applies neither.** Probed live, and re-probed while writing this: for `cik=0001067983` the endpoint returns a byte-identical 52-row payload spanning 2013-06-30 to 2026-03-31 unfiltered, with `year=2023&quarter=3`, and with `year=2019&quarter=1`. The five sibling `*_by_quarter` methods were probed the same way and do filter correctly -- 2023 Q3 returns 2023-09-30 rows, 2019 Q1 returns 2019-03-31 rows. This one is the exception. So `get_holder_performance_summary_by_quarter(cik, 2023, 3)` hands back thirteen years of data while its name promises one quarter. That is a silent wrong answer, and a more convincing one than the date-shaped form, because the name itself is the promise. Nothing said so: the docstring read "Wire shape when year/quarter are known", and the CHANGELOG listed it beside five siblings that work. Both routes now emit a UserWarning naming the behaviour and pointing at the `date` field on each row. Omitting `report_date` is the supported call and stays silent -- warning there would train users to filter the whole category out and take the two real warnings with it. The public signatures are unchanged. Internally the two public methods no longer call each other; both go through a private request helper that does not warn. That is what lets the warning fire exactly once and be attributed to the caller's line on either route instead of to `client.py`, which is the #177 failure mode. A test asserts the attribution rather than trusting the stacklevel arithmetic. This warning existed in #196 and was lost when #198 reworked that change, so it is now pinned by tests on both the sync and async clients -- if it is dropped again, the suite says so. If dropping it was deliberate rather than accidental, the docstring and CHANGELOG notes stand on their own and the `warnings.warn` calls can come out. **`fmp_data.__version__` was `0.0.0` in every release ever published.** `[tool.hatch.version] source = "vcs"` sets the distribution version, but without the matching build hook `fmp_data/_version.py` is never generated, so the import in `__init__.py` always fell back to the literal. Confirmed against PyPI: 2.5.0 reports dist metadata `2.5.0` and `__version__` `0.0.0`. Adding the `version-file` hook makes them agree, verified by building a wheel and installing it. `__version__` is in `__all__`, and it is what users paste into bug reports. Side effect worth having: two 3.0 tripwires are written `if __version__ != "0.0.0": assert major < 3` and had therefore never run in any install. They are live now. Verified: 1487 unit tests with extras, 1251 core-only, ruff and mypy clean, wheel contains `_version.py`, and the inert filter re-probed against the live API. Co-Authored-By: Claude Opus 5 (1M context) * fix(ci): stop silent successes in the release path (#202, #203, #204) (#205) Closes #202 Closes #203 Closes #204 * fix(ci): PAT-triggered automation PRs, release health checks, sync harden (#206–#208) (#209) * fix(ci): PAT-triggered automation PRs, release health checks, sync harden Address #206, #207, and #208 on top of the #205 release-path fixes: - Prefer secrets.GH_TOKEN (PAT) for Release-PR and Sync-Main-to-Dev so opened PRs re-trigger pull_request CI; fall back to GITHUB_TOKEN with a warning (#206) - On every dev push, re-validate an already-open release PR for ancestry and mergeStateStatus instead of green "already open" forever (#207) - Sync: cancel-in-progress false; refuse force-push over human WIP on sync/main-to-dev; open a tracking issue on merge conflicts (#208) * chore(deps): raise floors to current PyPI stables; fix list COLUMNS Audit of direct dependencies against PyPI (2026-08-10): core already current; raise optional/dev/docs floors (mcp 2.0, langchain stack, ruff 0.16.2, mypy 2.3, twine 7, …). Sync pre-commit ruff-pre-commit to v0.16.2. GH Action pins were already latest; pip-audit clean. Also make fmp-mcp list honour COLUMNS under TERM=dumb so the #163 copy-paste guards stay green with rich 15. * fix(ci): fail-closed release PR mergeability via REST retries (#207) The existing-PR health check used GraphQL mergeStateStatus and only matched lowercase "dirty", so DIRTY never failed that branch; UNKNOWN snapshots were also treated as healthy. Align with Guard-Main-Origin: REST mergeable / mergeable_state, retry unknown, fail closed after retries. * fix(ci): share fail-closed PR mergeability check (#210) (#211) Closes #210. Extract shared fail-closed REST mergeability check for Guard-Main-Origin and Release-PR; Guard pins action from base when present; Release conflict-guidance restored; classified gh api failures; local mock matrix. Follow-ups: #212 (CI mock matrix), #213 (require mergeable=true decision). * fix(ci): require mergeable=true and run mock matrix in CI (#212, #213) Stricter fail-closed contract: green only when REST mergeable is true (empty/null no longer succeeds). Wire the local check-pr-mergeable matrix and shellcheck into Test-Matrix so the Guard/Release action cannot rot. * fix(ci): tighten mergeable check review follow-ups Address PR #214 review findings without expanding contract scope: - Document Guard fail-closed mergeable=true wording in releasing.md - Fail shellcheck job when no action scripts are found; assert harness paths - Clarify null vs empty @tsv mock cases; lock jq null→empty encoding - Log actual mergeable value when refusing green (#213) * docs(ci): document Guard base-pin lag for mergeability (#218) (#220) * docs(ci): document Guard base-pin lag for mergeability (#218) Guard overlays check-pr-mergeable from origin/ when present, so contract tightenings apply to Guard only after they land on the PR base (usually main), while Release-PR on dev picks them up immediately. Document the lag and pin tradeoff for operators; do not silently change the pin strategy. Closes #218 * docs(ci): clarify Guard base is always main (#218 follow-up) Tighten table wording and cross-link the guard-main-origin overlay step so operators can find the pin implementation quickly. * fix(ci): extract mergeable with jq tostring (#216) (#219) * fix(ci): extract mergeable with jq tostring (#216) JSON null via bare @tsv became an empty TSV field, which was ambiguous for operators and mocks. Use (.mergeable|tostring) so null is the literal token "null". Fail-closed contract unchanged (green only when mergeable=true). Update mock encoding locks and action contract comments. Closes #216 * fix(ci): map null mergeable_state to unknown (#216 follow-up) After tostring extraction, a null mergeable_state became the token "null" and could fall into the resolved-state branch. Map null state to "unknown" so we stay on the unresolved retry/fail-closed path even when mergeable reports true. * fix(ci): retry transient gh api failures and harden mocks (#217, #215) (#221) #217: Bounded api-max-retries with linear backoff for 5xx/429/transport and secondary rate-limit 403; permanent 401/403/404 fail immediately. API exhaustion aborts the step. Success still requires mergeable=true. #215: Mock matrix asserts GITHUB_OUTPUT/step summary, edge cases (true+unknown, true+dirty, invalid sleep/api retries, unknown-shape API errors), and EXPECTED_PASS lock. Closes #217 Closes #215 * fix(ci): shared mergeable.jq + primary rate-limit retry (#223) (#224) - Single mergeable.jq extraction used by check.sh and the mock matrix - JSON mock path applies production jq end-to-end - Primary REST rate-limit 403 is transient (authz 403 stays permanent) - Clarify API_MAX_RETRIES is max attempts per poll Closes #223 * docs(changelog): fold Unreleased into 2.6.0 for release cut (#225) Move CI hardening (#202–#224), dependency floor bumps, and the fmp-mcp COLUMNS fix from Unreleased into the 2.6.0 section; date the release 2026-08-10 and note release-automation fixes in the intro. * fix(ci): auto-merge main→dev sync PRs without admin bypass (#228) Enable auto-merge (merge commit) on post-release history sync PRs; document Protect Dev dropping required_signatures so unsigned automation no longer needs --admin. * feat(client): absorb FMP changelog diluted PE, screener page, senateID (#231) * feat(client): absorb FMP changelog diluted PE, screener page, senateID (#229) Type the 2026-07-30 diluted PE/PEG fields on ratios and ratios-ttm (including ratios-ttm-bulk headers), add optional company-screener page (omitted when unset), and bind senateID on Senate and House trade models. Rating-bulk score columns become first-class on CompanyRating. CHANGELOG Unreleased now opens with a scan table of new, updated, and deprecated FMP surfaces from the 2026-08-12 live /stable re-probe. * fix(client): coerce rating-bulk scores and close review gaps (#229) rating-bulk CSV can emit "3.0"; a strict int field made parse_csv_models skip the whole company. Coerce those cells like profile-bulk volume. Assert all six scores as first-class fields, cover async screener page=1, and drop the House wording from SenateTrade.senate_id. * feat(company): wire get_delisted_companies to the slim live shape (#234) * feat(company): wire get_delisted_companies to the slim live shape (#229) The /stable/delisted-companies path was declared as CompanyProfile and had no client method. Add DelistedCompany, sync/async get_delisted_companies(page=0, limit=100), and the MCP/LangChain company.delisted_companies tool (now in DEFAULT_TOOLS). * fix(company): lock delisted slim-row parsing and page/limit defaults Review follow-up for #234. Python clients already forwarded page=0/limit=100, but the endpoint declaration had no defaults so LC schemas showed None. Add the same defaults as mergers-acquisitions-latest, parse a slim live row through BaseClient._process_response so a CompanyProfile regression fails in unit CI, and pin withdrawn-tool counts in docs/mcp/tools.md. Broader Endpoint[T] vs list[T] typing left as #235. * docs(mcp): position FMP hosted MCP vs fmp-mcp (#236) * docs(mcp): position FMP hosted MCP vs fmp-mcp (#230) Record the A+D posture: docs + coverage matrix, no runtime helper. Live tools/list on 2026-08-12 returned 28 dataset tools (changelog said 27). * docs(mcp): fix hosted matrix mapping and stop hardcoding catalog counts Point earningsTranscript at transcripts.* (not intelligence). Drop the stale 223/137 catalog numbers in favor of tools.md. Assert the 28-row matrix instead of a bare "28" substring. * docs(mcp): tighten hosted MCP wording, example, and matrix guard CodeRabbit CLI follow-up: say "broader MCP tool catalog" in the README, make the FastMCP snippet a runnable asyncio script, and compare matrix first-column names to the probed tool set instead of a row count. * fix(batch): honor FMP_VALIDATION_MODE in parse_csv_models (#237) * fix(batch): honor FMP_VALIDATION_MODE in parse_csv_models (#232) Unknown bulk CSV headers now follow the same warn/strict/lenient extras policy as JSON. Invalid cells still skip the row unless strict, which fails the request. Rating scores coerce only whole values. * test(batch): assert extras warnings via the FMPLogger module logger caplog on fmp_data.base stays empty because FMPLogger.get_logger(__name__) emits on fmp_data.fmp_data.base. Patch fmp_data.base.logger like the existing extras tests. Also correct the coerce_rating_score docstring: non-integral scores raise, they do not pass through. * fix(fundamental): reject high-precision fractional rating scores Parse rating-bulk score strings with Decimal so "3.0000000000000001" is not rounded to 3 by float(). Integers skip the float() path, which also avoids OverflowError on huge ints. * fix(logger): stop doubling fmp_data. in get_logger(__name__) (#240) * fix(logger): stop doubling fmp_data. in get_logger(__name__) get_logger(__name__) now returns fmp_data.base instead of fmp_data.fmp_data.base, so caplog and log filters on the module name see extras/HTTP warnings. Handler and filter attachment is unchanged. Closes #238. * test(logger): pin logger names and document doubled-name migration Assert record.name so caplog cannot pass if the prefix bug returns. Note that operators who already filtered fmp_data.fmp_data.* need to switch to fmp_data.*. * feat(client): type list endpoints via request_list and _unwrap_list (#239) * feat(client): type list endpoints via request_list and _unwrap_list Keep request() as Endpoint[T] -> T | list[T]. List-returning company methods now unwrap through _unwrap_list so endpoints can be Endpoint[T] (the row type) without a cast or the PROFILE_CIK pattern. Closes #235. * test(client): cover company list unwrap and document request_list Parameterized wrap-single/empty tests execute every company _unwrap_list wrapper so codecov/patch can clear 80%. _unwrap_list_result now uses the row type as a witness before the list check. CHANGELOG no longer claims company methods call request_list. * fix(logger): route leftover entry points through FMPLogger (#243) Production logging.getLogger(__name__) sites now use FMPLogger so names stay on the fmp_data.* tree. LC class-name loggers use __name__ plus getChild(class) instead of fmp_data.ValidationRule. Extras tests listen on fmp_data.base via caplog. Handler and filter attachment is unchanged. Closes #241. * feat(client): migrate remaining list endpoints through _unwrap_list (#244) * feat(client): migrate remaining list endpoints through _unwrap_list Bind remaining domain list endpoints as Endpoint[T] (the row type) and normalize request() / request_async() through _unwrap_list. Company historical EOD helpers unwrap rows then wrap HistoricalData. request() stays T | list[T]; methods are not switched to request_list. Closes #242 * test(client): cover remaining-domain unwrap twins and isolated review nits Expand test_list_unwrap to async/sync tables so codecov/patch can clear 80%. Async alternative history uses AsyncEndpointGroup._unwrap_list; technical indicators return list[T]. Changelog notes the PortfolioDate annotation correction. * feat(client): migrate leftover list endpoints through _unwrap_list (#246) Type index, SEC list, transcripts, and batch quote endpoints as Endpoint[T] (row type) and normalize request() / request_async() with _unwrap_list. SEC profile stays _unwrap_single. Bulk-bytes / CSV paths are unchanged. request() stays T | list[T]. Closes #245 * feat(batch): type bulk-bytes endpoints as Endpoint[bytes] (#248) Bind PROFILE_BULK, EOD_BULK, statement bulks, and the other CSV downloads as Endpoint[bytes]. _request_csv stays the only bytes helper. Quote lists stay on _unwrap_list. request() stays T | list[T]. Closes #247. Follow-up: #249, #250. * feat(client): overload request() for bytes and type report endpoints (#251) * feat(client): overload request() for bytes and type report endpoints Add request(Endpoint[bytes]) -> bytes overloads. request_list refuses bytes so a file is not wrapped as [bytes]. Bind FINANCIAL_REPORTS_JSON and FINANCIAL_REPORTS_XLSX as Endpoint[T]. request() stays T | list[T] for every other T. Closes #249. Closes #250. * fix(client): cover bytes-list refuse and report fallbacks Add request_list NoReturn overloads, widen XLSX payloads like JSON, and test the dict/error/async-bytes paths that left patch coverage below the 80% gate. * chore(coverage): exclude typing overload stubs from patch reports @overload bodies are never executed. They were the last eight uncovered patch lines and kept codecov/patch under 80%. * fix(ci): isolate PyPI publish and pin Actions to full SHAs (#254) Split build and publish so OIDC tokens never share a job with unlocked build tools. Pin every external action to a commit SHA, install the PEP 517 frontend from a hashed lockfile, bind TestPyPI dispatch to dev, and require same-repo heads for labeled TestPyPI PRs. Refs #252 * fix(mcp): parse manifests as data instead of executing them (#255) * fix(mcp): parse manifests as data instead of executing them fmp-mcp validate and server startup treated user-supplied .py manifests as importable modules, so a file named "validate" could run arbitrary code with FMP_API_KEY in the environment. Load TOOLS = ["..."] via a restricted AST, accept JSON/YAML/TOML, and fail closed on imports or calls. Refs #252 * fix(mcp): load TOML manifests on Python 3.10 Advertised TOML support failed on the 3.10 floor because tomllib is 3.11+. Use tomli from the mcp extra there, and add tests that a call or f-string inside TOOLS = [...] is refused without side effects. * fix(client): refuse bytes in _unwrap_list_result (#257) request_list already raised TypeError for Endpoint[bytes], but the shared unwrap helper still wrapped a CSV/XLSX body as [bytes]. Quote list unwrap is unchanged. Closes #253 * feat(mcp): write generate output from the file suffix (#258) JSON is preferred. YAML/TOML use a tools list. A path with no suffix becomes .json. Legacy .py still writes TOOLS = [...] so existing scripts keep working. Unknown suffixes are refused. Closes #256 * fix(client): keep API key on origin and require HTTPS (#259) Do not attach apikey as a default header (a 302 would forward it). Refuse cross-origin redirects. base_url must be HTTPS except loopback. Refs #252 * fix(logger): redact child loggers and api_key=%s (#260) Format before masking so %s is not treated as the secret. Attach the filter to each handler and each child logger. Refs #252 * fix(mcp): write Claude config as 0600 and hide key prompt (#261) Atomic replace, directory 0700, backups 0600. API key uses getpass. Refs #252 * fix(config): redact secondary secrets in str/repr (#262) Hide embedding API keys and redis URL userinfo. Nested cache URLs on ClientConfig are redacted the same way. Refs #252 * fix(test): do not log or reprint API keys in VCR helpers (#263) DEBUG logs host/path only. Cassette leak assertions name type, not value. Refs #252 * fix(company): whitelist intraday interval and encode path params (#264) Reject traversal in path segments. Company interval matches the crypto/forex/commodity list. Refs #252 * docs: add SECURITY.md with private disclosure path (#267) Refs #252 * fix(ci): require TestPyPI tags to sit on main or dev (#268) Refs #252 * fix(ci): drop Claude OIDC and review write on contents (#266) Review is contents:read. Interactive Claude keeps contents:write for commits. Neither job needs id-token:write. Refs #252 * fix(make): load .env as KEY=VALUE data, not shell (#265) scripts/export_dotenv.py quotes values. Makefile no longer sources .env. Refs #252 * fix(ci): freeze hatchling and hatch-vcs for isolated publish (#269) Pin the PEP 517 backend next to the already-hashed frontend and build with --no-isolation so publish jobs cannot pull an unlocked hatchling from PyPI (#252 leftover from FMP-SEC-002). * fix(ci): audit a live extras export with pip-audit --strict (#270) * fix(ci): audit a hashed extras export with pip-audit --strict Add .github/requirements-audit.txt for the published extra graph (langchain, mcp, cache-redis) and fail the security session on unpinned or unresolved requirements (#252 FMP-SEC-008). * fix(ci): audit a live extras export and drop the hashed lock Resolve langchain/mcp/cache-redis from pyproject at session time so a floor bump is enough to pick up newer deps. Nox installs the dev group from pyproject instead of a second, stale pin list. * fix(ci): scan the committed tree for secrets, not only cassettes (#272) CI no longer no-ops when VCR cassettes are absent. detect-secrets covers tests via the baseline; cassettes stay zero-tolerance when present (#252 FMP-SEC-008). * ci: add SHA-pinned CodeQL analysis for the Python tree (#271) * ci: add SHA-pinned CodeQL analysis for the Python tree Fail-closed code scanning on PRs, pushes to main/dev, and a weekly schedule (#252 FMP-SEC-008). * ci: note that CodeQL default setup must stay disabled The first Analyze job uploaded SARIF and GitHub rejected it because default setup was still on. Default setup is now not-configured. * fix(ci): install publish backends from version floors (#274) Drop the hashed exact pin file added in #269 so a floor bump (or a newer compatible release) is enough to update build/hatchling/hatch-vcs. Keep --no-isolation so PEP 517 isolation cannot pull a different backend. * chore(ci): bump setup-uv to v10 and track latest uv (#275) * chore(ci): bump setup-uv to v10 and track latest uv Pin the action to the v10.0.0 SHA and set version: latest so uv itself can move without another workflow edit. * test(ci): avoid a raw SHA constant in the setup-uv pin test detect-secrets treats a 40-char hex literal as a high-entropy secret. Assert the pin from the workflow line instead. * chore(ci): pin uv 0.12.3 locally and in GitHub Actions (#276) Local uv is already 0.12.3. Require that floor in pyproject.toml and install the same version in CI instead of a floating latest tag. * fix(client): refuse cross-origin redirects via Location (#252) (#277) * fix(client): refuse cross-origin redirects via Location (#252) The SEC-004 hook inspected response.next_request, which httpx only sets when follow_redirects is false. Production uses follow_redirects true, so the check was a no-op. Resolve Location the same way httpx does and compare normalized origins. * fix(client): refuse invalid Location and cover redirect origin edges The hook now fails closed when Location is unparsable. Tests cover next_request fallback, relative and scheme-relative hops, default and explicit ports, and the async client hook. * fix(sec): narrow Bandit skips to library asserts (#273) (#278) Drop the global B404/B603/B607/B608 skips. MCP subprocess calls use argument lists and keep file-local nosec notes. B608 is unused. * fix(make): drop process-hijack keys from .env export (#273) (#279) Ignore PATH, LD_PRELOAD, PYTHONPATH, DYLD_*, and similar identifiers so a trusted local .env cannot rewrite the maintainer shell. * fix(lc): redact secret-shaped embedding kwargs in repr (#273) (#280) * fix(lc): redact secret-shaped embedding kwargs in repr (#273) additional_kwargs can carry provider keys. Mask those values in repr and keep api_key off the default field repr. * fix(lc): match secret kwargs on whole path segments Substring 'auth' was redacting 'author'. Split on '_' / '-' so http_auth is masked and author stays visible. * fix(ci): add extras coverage gate and expand MCP session (#273) Keep the core 80% omit list. Measure lc/mcp/Redis separately at 65%, require extras-coverage in Test-MatrixExpected, and run every tests/unit/test_mcp*.py file in the mcp-server session. * fix(sec): repair async client, tag guard, and CI enforcement gaps (#252, #273, #282) (#283) Closes the remaining items on #273 and #282, and fixes four real defects found while re-auditing #252's acceptance criteria against dev. Fixed * Async requests were broken outright. httpx.AsyncClient awaits response hooks (`await hook(response)`) while httpx.Client calls them plainly. The FMP-SEC-004 redirect guard was a plain `def` registered on both clients, so httpx awaited None and *every* successful async response raised `TypeError: object NoneType can't be used in 'await' expression` -- not only redirects. Adds `_areject_cross_origin_redirect`. Unreleased: never shipped in a tagged version. The async tests replaced the client with AsyncMock and asserted only hook membership, so nothing exercised the real send path; they now drive httpx.MockTransport and assert the hook is a coroutine function. Three of the four new tests fail without the fix. * TestPyPI tag-ancestry guard was bypassable. It compared against the unqualified rev `origin/main`; git resolves refs/tags/ before refs/remotes/, and checkout with fetch-depth:0 mirrors tags verbatim, so a tag named `origin/main` shadowed the branch and the guard compared HEAD against an attacker-chosen commit -- passing with only an "ambiguous refname" warning, which `set -euo pipefail` does not trap. Reproduced against a scratch remote; now uses refs/remotes/... and fetches with --no-tags. * Bandit ran nowhere in CI. #278 narrowed the B404/B603/B607/B608 skips to file-local `# nosec` notes, but bandit lived only in pre-commit and there is no pre-commit CI job, so the narrowing was unenforced. Now part of `nox -s security`; 0 findings. The same #278 change had left the Claude Desktop example unannotated, so `pre-commit run --all-files` was already red on dev; it gets the same notes. * Embedding `additional_kwargs` redaction is now recursive. The top-level scan copied nested containers by reference, so a credential under a real provider kwarg (`default_headers`, `model_kwargs` -- both accepted by OpenAIEmbeddings and splatted through) was printed verbatim by repr. Changed * Extras coverage 66.96% -> 86.98% via dedicated tests for the weakest and most security-relevant files: cache/redis_backend.py (25->100%), lc/validation.py (30->100%), lc/__init__.py (59->100%), mcp/utils.py (52->100%), mcp/setup.py (24->99%). fail_under raised 65 -> 80, leaving ~7 points of headroom now that the job is a required check (#282). * Fixed the `Protocol` exclusion in extras.coveragerc: copied from TOML with a doubled backslash, which in an INI file is a literal backslash, so it compiled but never matched. * uv.lock stays uncommitted, now recorded as a decision in CONTRIBUTING.md. The old .gitignore rationale cited a non-existent "GitHub 500KB warning" (that is pre-commit's check-added-large-files default). * mypy: warn_unused_ignores off for tests only. Tests are checked in two dependency environments (core-only in the typecheck session, all-extras locally); an ignore required when a stub meets a typed langchain signature is redundant when that package is absent, so the two envs were mutually unsatisfiable. The library keeps the strict global setting. Repo settings (applied out of band, #282): `Extras Coverage`, `coverage`, `Secret Scan`, `Actions shell checks` and `Test-MatrixExpected` are now required status checks on Protect Dev and Protect Main. Previously only tests (3.10-3.14) were required, so a red extras or secret scan could not block a merge. Verified: 2258 passed / 7 skipped in both the no-extras and all-extras environments; ruff, mypy (229 files), bandit, pip-audit --strict and the full pre-commit suite clean. Co-authored-by: Claude Opus 5 (1M context) * Build(deps): Bump upload-artifact to 7.0.1 and download-artifact to 8.0.1 (#287) * Build(deps): Bump actions/upload-artifact from 4.6.2 to 7.0.1 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 7.0.1. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/ea165f8d65b6e75b540449e92b4886f43607fa02...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * fix(ci): bump download-artifact with upload-artifact to the paired majors upload-artifact v7 and download-artifact v8 share the current artifact backend. Landing only one half leaves release jobs on mixed majors. Keep pins internally consistent and assert both halves are present. --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Mehdi * Build(deps): Bump github/codeql-action init and analyze to 4.37.6 (#286) * Build(deps): Bump github/codeql-action/init from 3.37.7 to 4.37.6 Bumps [github/codeql-action/init](https://github.com/github/codeql-action) from 3.37.7 to 4.37.6. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/f3712979fa5f215279b101dd0a2e3bdfb4353324...5595ccaf912efad79be6eef63a5619ff05969be3) --- updated-dependencies: - dependency-name: github/codeql-action/init dependency-version: 4.37.6 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * fix(ci): keep CodeQL init and analyze on the same v4 pin Dependabot opened separate PRs for codeql-action/init and codeql-action/analyze. Mixed v3/v4 fails Analyze Python with "Loaded a configuration file for version X, but running version Y". Bump analyze to the same v4.37.6 SHA as init and lock the pair in a unit test. --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Mehdi * docs: raise extras coverage floor in the testing guide to 80% (#289) #283 moved extras.coveragerc and nox -s coverage_extras from 65 to 80. The contributor guide still advertised 65%, so a local run that passed the documented floor would fail CI. * ci(dependabot): group CodeQL and artifact action updates (#288) Dependabot opened one PR per action id, so CodeQL init/analyze and upload/download-artifact landed as incompatible split majors. Group each pair so the next weekly run opens a single lockstep PR. * fix(ci): run the quality gates that were silently skipped (#252) (#290) `ci.yml` invoked the gates as `nox -s lint -s typecheck -s security`. nox's `-s` is a *store* argument with `nargs="*"`, not an `append`, so repeating the flag discards every earlier occurrence: only `security` was ever selected. `ruff` and `mypy` had no CI enforcement at all, and bandit/pip-audit survived purely by being last in the list. Confirmed against the last green run on dev (31417673277) -- every `nox > Running session` line in that job is a tests-*, coverage_local or security session. No lint. No typecheck. This is #283's bandit finding one layer up: there the tool was configured but unwired; here the job exists and reads correctly, so grepping ci.yml for "lint" passes while the session never runs. Three changes: - One session per nox invocation, so the selection cannot collapse again and each gate's failure stays unambiguous in the log. - Exclude `docs/superpowers` from ruff. Turning the gate back on surfaced a pre-existing red: `ruff format` reflows Python inside Markdown fences, and those dated plan/spec documents record partial *excerpts* -- bodies lifted out of their enclosing function. Ruff re-indents them as standalone modules, which corrupts the record rather than tidying it. Without this the required Test-Matrix check goes red on every PR. - A meta-test reproducing nox's own flag semantics rather than matching substrings, since a membership assertion passes on exactly the line it exists to reject. `test_repeated_s_flag_form_is_detected_as_broken` pins that reproduction against the real known-bad string, and the suite goes red when ci.yml is reverted (verified). Also asserts the gate's `if:` names a Python version present in the matrix -- otherwise dropping 3.14 would skip the step and still report success. The restored mypy gate immediately caught two `no-any-return` errors in the new test file itself, which are fixed here. 2267 passed, 7 skipped; lint and typecheck green. Co-authored-by: Claude Opus 5 (1M context) * fix(sec): stop the log filter crashing on Bearer and leaking tracebacks (#252) (#291) Two defects in `SensitiveDataFilter`, both inside the FMP-SEC-005 remit ("sanitize extras, exception text, and tracebacks") that the original pass only half delivered. 1. `mask_replacement` read `match.group(3)` unconditionally, but the `authorization` pattern is `(Authorization:\s*Bearer\s+)(\S+)` -- two groups. Any record containing a Bearer token raised `IndexError: no such group` from inside `logging`. So the Bearer rule never redacted anything, *and* the filter's own exception replaced the caller's. That is reachable without contrivance: `FMPDataClient.__exit__` logs with `exc_info=True`, so a `RuntimeError` whose message carried a Bearer token surfaced to the user as the filter's IndexError instead. The replacement is now group-count aware. 2. Tracebacks were never redacted at all. Filters run *before* formatting, so `record.exc_text` is still `None` when the filter inspects it -- the `exc_text` branch could not fire on a live `exc_info=True` call, and the traceback reached the handler carrying whatever credential the raised exception's message held. The filter now renders and masks the traceback itself; `logging.Formatter.format` reuses a non-empty `exc_text` rather than re-deriving it. `JsonFormatter` needed the same treatment independently: it builds its exception block from `record.exc_info` directly, so it re-derived the raw traceback regardless of what the filter had done. (2) was found while writing the regression test for (1): the crash was gone but the token was still in the captured output. Tests drive every configured pattern rather than only the three-group ones, and assert the sample set matches `filter.patterns` so a new two-group rule cannot silently reintroduce this. Mutation-verified: each fix reverted independently turns the relevant tests red (3 and 1 respectively). Co-authored-by: Claude Opus 5 (1M context) * fix(sec): redact API keys in non-JSON and scalar error bodies (#252) (#292) `handle_response`'s `json.JSONDecodeError` branch built `FMPError.response` straight from the raw bytes, while the 5xx sibling (`_get_error_details`) ran the same bytes through `_redact_api_keys`. The two branches disagreed about the same payload: 200 non-JSON -> {'raw_content': '... apikey=SECRETKEY_1234...'} LEAK 500 non-JSON -> {'raw_content': '... apikey=[REDACTED]'} ok This is routine rather than exotic. A 2xx carrying a non-JSON body is what WAF and CDN block pages return, and they habitually echo the full request URL -- which for this client always carries `apikey=`. `_parse_json_response` had the same gap for a bare JSON *string* body, which is how proxies return short error text; only dict/list bodies were sanitized. Both now redact. The body is also decoded with `errors="replace"`, as `_get_error_details` already did: a bare `.decode()` raises `UnicodeDecodeError` from *inside* the `except json.JSONDecodeError` block, replacing the real failure with a decoding one. Why this survived: every existing test drove the helper (`_get_error_details`) rather than the branch, so the asymmetry was never exercised. The new tests call `handle_response` itself through a real `httpx.Response`, and one asserts the two paths produce *identical* output -- pinning the parity, not just the fix, so the branches cannot drift apart again in either direction. Both `mock_response` fixtures (there are two; the one in test_base.py shadows conftest's) left `.content` an unset Mock, so `response.content.decode()` returned another Mock that flowed into error payloads unnoticed -- any code that actually inspected the body was never exercised. They now carry real bytes. The baseline line-number shift follows from that fixture edit. Mutation-verified: reverting the JSONDecodeError redaction reds 3 tests, reverting the scalar-body redaction reds 1. 2267 passed, 7 skipped; lint and typecheck green. Co-authored-by: Claude Opus 5 (1M context) * fix(ci): split the TestPyPI PR comment off the OIDC job (#252) (#293) `test-release-publish` held `pull-requests: write` and `issues: write` alongside `id-token: write` for one reason: to post the "test release published" comment. That put the TestPyPI publishing credential and repo-write capability in the same job, reachable by anything running there -- a compromised action, a malicious build dependency, an injected script step. The comment moves to `test-release-comment`, which has the write scopes and no OIDC token, no environment, and never downloads the artifact. `test-release-publish` is now `id-token: write` + `contents: read`, doing only download -> verify checksums -> upload. `test-release-comment` needs both upstream jobs, so it still resolves `needs.test-release-build.outputs.*` and only runs after a successful publish -- it must not advertise a version that was never uploaded. The new test walks *every* workflow rather than the one that was wrong, so a new job cannot quietly reintroduce the combination. It also pins the specific split, because the generic check would pass just as well if the comment step were deleted outright. The sweep surfaced one legitimate pairing: `document.yml`'s `deploy-docs` needs `pages: write` + `id-token: write` together, which `actions/deploy-pages` mandates. That token is a Pages deployment token, not a package-index one, and the job is already artifact-only with no checkout. Exempted narrowly -- keyed to the `github-pages` environment so a job publishing elsewhere cannot borrow it. Mutation-verified: restoring the combined permissions reds both tests. Co-authored-by: Claude Opus 5 (1M context) * fix(ci): drop the persisted git credential from the release build (#252) (#295) `release.yml`'s build job needs `contents: write` -- it pushes the release tag and creates the GitHub Release -- and that is legitimate. What it did not need was the credential *persisted in `.git/config` for the whole job*, including while it installs a PEP 517 frontend and backend and runs `python -m build`. Anything in that dependency tree could read a repo-write token straight off disk. Surveying the job, exactly one operation needed remote write: :147 git push origin "$RELEASE_TAG" Everything else is local git reads (`describe`, `rev-parse`, `log`) or `gh` already using a step-scoped `GH_TOKEN`. So the tag push moves to the API and the checkout drops credentials entirely. `GH_TOKEN` is declared on the two steps that need it, never at job level -- the build step must not see it. Two orderings this depends on, both easy to destroy while tidying, so both are now pinned by tests: - The tag is created **locally first** and kept. `hatch-vcs` derives the version from the local tag and the build step runs after this one, so a remote-only tag would silently produce a `.devN` version instead of the release version. (The `EXPECTED_VERSION` guard would catch it, but only after the tag was already public.) - The remote tag is created as an annotated tag **object** and then a ref, not a bare ref -- otherwise the remote gets a lightweight tag where the local one is annotated. The push is verified via `git/ref/tags/...` before anything downstream depends on it, so a failure surfaces here rather than as a less legible `gh release create --verify-tag` error later. Note this path cannot be exercised without performing a real release -- CI's shellcheck job only covers `.github/actions/**/*.sh`, not inline `run:` blocks. The script passes `bash -n` and `shellcheck` locally, and the meta-tests stand in for the rest. Worth watching the next release. Mutation-verified: restoring `token:` on the checkout reds the guard. Co-authored-by: Claude Opus 5 (1M context) * fix(sec)!: type credential config fields as SecretStr (#252) (#294) BREAKING CHANGE: `ClientConfig.api_key`, `LangChainConfig.embedding_api_key`, `EmbeddingConfig.api_key` and `CacheConfig.redis_url` are now `SecretStr`. Read them with `.get_secret_value()`. `repr` and `str` were hand-redacted long ago, but the dumps were not: repr / str -> masked model_dump() -> LEAKS {'api_key': 'SUPERSECRETKEY123456', ...} model_dump_json() -> LEAKS {"api_key":"SUPERSECRETKEY123456", ...} so anything serializing a config -- structured logging, a JSON error report, a config endpoint -- emitted the key while every eyeball-level check looked clean. All four models leaked, including `redis_url` userinfo both directly and nested under `ClientConfig.cache`. A hand-written `__repr__` cannot close this: a third party calling `model_dump()` never goes through it. Typing the fields moves the guarantee into pydantic, where every serialization path inherits it. Construction is unchanged -- a plain `str` still coerces -- and truthiness still works, so `client.py` / `async_client.py`'s `not config.api_key` guards are untouched. What changes is *reading* the field. Four traps, each verified against pydantic 2.13.4 / httpx 0.28.1 rather than assumed: - **The wire fails silently.** httpx accepts a SecretStr and stringifies it, so a missed unwrap sends `apikey=**********` and 401s with no local error. Both `base.py` sites are unwrapped explicitly. The existing guard asserted `called_params["apikey"] == client_config.api_key`, which passes *vacuously* once both sides are masked -- it now asserts the literal value. - **`str_strip_whitespace` does not reach inside a SecretStr**, and SecretStr has no `.strip()`. The validator strips explicitly. - **`lc/config.py:63` must stay on python-mode `model_dump()`.** It round-trips SecretStr objects unchanged. `mode="json"` -- the reflex, since SecretStr is not JSON-serialisable -- substitutes the literal mask instead. `validate_api_key` now rejects an all-asterisk value, so that mistake fails loudly at construction rather than as a 401 later. - **`__str__` must mask from the field, not the dump.** The dump is already `SecretStr('**********')`; re-masking it prints a mask of a mask and loses the leading-4-character affordance that lets you tell two keys apart. The restored mypy gate (#290) caught two integration call sites in `tests/integration/test_lc.py` that the unit suite never exercises. README updated; `create_vector_store` there is fed a plain string. Mutation-verified: reverting `api_key` to `str` reds 6 tests including the primary leak assertion; reverting `redis_url` reds 3. 2284 passed, 7 skipped; lint and mypy green. Co-authored-by: Claude Opus 5 (1M context) * fix(sec): escape the symbol in get_company_logo_url (#252) (#296) `get_company_logo_url` assembles its URL with a raw f-string and never calls `Endpoint.build_url`, so it never inherited the path sanitizing added when FMP-SEC-010 was closed. That fix's acceptance note said "a whole-package sweep found no other unconstrained path param" -- the sweep covered `build_url` callers, and this builder is not one. Reproduced on dev: get_company_logo_url("../../stable/profile?apikey=x&z") -> https://financialmodelingprep.com/image-stock/../../stable/profile?apikey=x&z.png `symbol` is not a trusted input here: `company_logo_url` is a registered MCP tool (`company/mapping.py:646`), so the value arrives from an LLM. Bounded, and lower severity than the original SEC-010: this returns a string rather than issuing a request, and no API key is attached, so the worst case is a caller fetching a different path on the same origin -- `..` normalizes within the host and cannot change it. Still a real hole in a finding recorded as closed. Both sync and async now route `symbol` through `_safe_path_segment`, the same helper `build_url` uses, so the rule stays defined in one place. Dotted tickers such as `BRK.B` still work -- only `.` and `..` *segments* are traversal. Mutation-verified: reverting either builder reds 10 tests. 2302 passed, 7 skipped; lint and mypy green. Co-authored-by: Claude Opus 5 (1M context) * fix(sec): drive config redaction from metadata, not a name allowlist (#252) (#297) `ClientConfig.__str__` dumped the whole model and masked the three field names it happened to know about, so everything else rendered verbatim: B handler_kwargs secret leak: True C subclass secret field leak: True D nested api_key in kwargs leak: True B needs no subclass. `LogHandlerConfig.handler_kwargs` is a bare `dict[str, Any]` handed straight to a logging handler, so a credential in it reached `str()`/`repr()` of a stock config. Redaction now runs three passes, narrowest signal first: 1. Fields marked `repr=False` -- pydantic's own not-for-display marker, which every credential field already carries. A subclass gets correct behaviour from the standard idiom instead of by being added to a list here, which is the actual defect: the allowlist could only ever cover fields someone remembered to add. 2. A recursive sweep for secret-shaped *keys*, the only handle available on untyped bags. 3. The two values worth rendering richer than `***` -- the API key's leading characters and the Redis host. Over-redaction would make the string useless, so `maxBytes`, `backupCount` and `base_url` are all still shown, and the token match is on whole `_`/`-`/camelCase-separated parts (`keyboard` and `monkey` are not secrets). The shape rules move to `fmp_data/_redaction.py` and are now shared with `EmbeddingConfig`, which had its own copy that recursed while the config side did not. Writing direct tests for the shared module immediately caught a bug in the camelCase splitter: splitting on every capital shredded `API_KEY` into `["a","p","i",...]`, matching nothing. The boundary is a lower-to-upper transition. Also closes the same hole on `EmbeddingConfig.additional_kwargs`, which `SecretStr` cannot reach into: a `field_serializer` redacts it on dump as well as in `repr`. Safe there and not on `ClientConfig` because every consumer reads the attribute (`**self.additional_kwargs`), whereas `LangChainConfig.from_env` rebuilds itself from `ClientConfig`'s dump. Mutation-verified: dropping the recursive sweep reds 4 tests, dropping the `repr=False` pass reds 1, disabling the serializer reds 2. 2363 passed, 7 skipped; lint and mypy green. Co-authored-by: Claude Opus 5 (1M context) * fix(ci): keep credentials out of jobs that do not need them (#252) (#298) Three separate ways a token sat somewhere it had no business being. **Checkouts.** Only 4 of 17 `actions/checkout` steps set `persist-credentials: false`, so the rest wrote the job's token into `.git/config` where every later step could read it -- dependency installs, PEP 517 backends, third-party actions. Ten more now drop it. All of them only ever read (`git fetch` / `git describe` / `git ls-files`) and this is a public repo, so anonymous fetch is enough. Two keep the credential, documented in place rather than silently skipped: - `sync-main-to-dev.yml` really runs `git push`, and note it persists a *PAT*, which is broader than GITHUB_TOKEN. - `claude.yml` runs claude-code-action, whose default `use_commit_signing: false` is documented as "Claude uses standard git commands" -- dropping the credential would silently break @claude's ability to push. Checked against the action's own `action.yml`. The test asserts that exception list by name, so a third one has to be a deliberate edit rather than an omission. **Codecov.** `nox -s coverage_local` runs the PR branch's own noxfile and test suite, and CODECOV_TOKEN was injected into that same job. Coverage now uploads `coverage.xml` as an artifact and a separate job with no repository code performs the upload -- the build/publish split the release path already uses. Deliberately *not* added to the required checks: the coverage threshold is still enforced in the `coverage` job, and a telemetry upload should not gate merges. **Release-notes heredoc.** Made deliberate rather than accidental. This was *not* exploitable as written, and the finding overstated it: `--pretty=format:"- %s"` prefixes every commit line, so a commit subject of `EOF` emits `- EOF` and cannot close the heredoc. Verified both ways against a scratch repo -- 0 bare-EOF lines with the prefix, 1 without. `%s` also collapses a multi-line first paragraph, so a newline cannot be smuggled in either. But the protection is a side effect of a format string: drop the `- ` and every following line becomes a workflow command. The delimiter is now random per run. Not fixed, recorded as accepted: `claude-code-review.yml` fetches a plugin marketplace from a mutable git HEAD at runtime. The action's `plugin_marketplaces` input takes a bare Git URL with no documented ref syntax, so unlike every `uses:` here it cannot be pinned, and the repo's `sha_pinning_required` setting cannot see a runtime fetch. Same first-party origin as the SHA-pinned action, review-only job, no checkout credentials. Noted in the workflow. Also adds a concurrency group to `claude.yml` so two @claude mentions cannot race in one workspace while both hold contents:write. 2328 passed, 7 skipped; lint and mypy green. Co-authored-by: Claude Opus 5 (1M context) * fix(sec): make log redaction type-complete and record-level (#252) (#299) Four gaps in `SensitiveDataFilter` sharing one root cause: it gated on `isinstance(v, str | int | float)` to mask and `dict | list` to recurse, so anything else walked straight through. api_key as bytes leaked? True api_key as tuple leaked? True api_key as set leaked? True api_key as str (control) leaked? False 1. A sensitive key holding bytes/tuple/set was logged in the clear. Every container type is now walked. `bytes` is decoded before masking rather than having its `repr` masked -- not a leak either way (`_mask_value` masks the middle regardless) but `b'****E'` misstates both the value and its length. 2. A secret reachable only through a tuple was never reached. 3. Nested extras were redacted inside `JsonFormatter.format` instead of on the LogRecord, so a *second* handler saw the raw value from the same record: USERROOT call | extras={'api_key': 'SECRETKEY_...'} That is what `logging.basicConfig()`, a Sentry exporter or an OTel log exporter looks like -- i.e. the common case for a library consumer. Redaction now happens once on the record, so every handler inherits it. Standard LogRecord attributes are excluded by name: `exc_info` is a tuple formatters unpack, and rewriting it would corrupt them. 4. `log_api_call` put positional arguments into `call_args` as a raw tuple. Positional arguments have no names, so nothing downstream can distinguish a credential from a ticker symbol -- the filter masks by key and there is no key inside a tuple. It now logs their *types*. The single decorated method is `BaseClient.request(self, endpoint, **kwargs)`, so this loses nothing: the readable data arrives through `call_kwargs`, which is named and still masked. "call_args": ["str"] "call_kwargs": {"symbol": "AAPL", "api_key": "SE****90"} Also: the LangChain tool error envelope returned `str(e)` inside the dict that *is* the tool's return value -- it goes into the agent scratchpad, the chat history and any tracing backend. `str()` of an `httpx.HTTPStatusError` stringifies the request URL, which carries `apikey=`; that is the exact leak `base.py` suppresses on its own paths, while `grep -rl _redact_api_keys fmp_data/` matched only `base.py`. Redacted at the point of capture so every branch inherits it. The nested-`redis_url` case from the same family needed no fix: #294 made that field `SecretStr`, so the dump no longer carries it. Mutation-verified: reverting tuple recursion, record-level redaction, `call_args` types, and the lc envelope each red their own tests. 2375 passed, 7 skipped; lint and mypy green. Co-authored-by: Claude Opus 5 (1M context) * fix(ci): serialize publishes and stop ignoring ruff's bandit twins (#252) (#300) **Concurrency.** `release.yml`, `dev-release.yml` and `publish-testpypi.yml` had no `concurrency` group. Two pushes to the same PR could both build and publish, and the slower run's comment could overwrite the newer one -- advertising a stale version, which is the exact invariant #204 exists to protect. `cancel-in-progress` is deliberately `false`. Making the second run wait is strictly better than aborting one mid-upload; the staleness is fixed by serializing, since the newest run then finishes last. **Ruff's S-rules.** #278 removed `B603`/`B607` from `[tool.bandit] skips`, but their flake8-bandit twins `S603`/`S607` stayed in `[tool.ruff.lint] ignore`. The narrowing looked done while the rule was enforced by *neither* tool -- the same shape as every other finding on this issue: configured, not enforced. Removing them surfaced exactly five call sites, all benign and all already carrying `# nosec B603` from #278. Each now has a matching targeted `# noqa` with a reason, so the two tools agree instead of each assuming the other covers it. The global ignore is down to `S101`. `scripts/` was separately exempt from ruff's S rules (`per-file-ignores`) *and* from bandit (`exclude_dirs`), so its one file was scanned by neither. It uses no subprocess and passes both cleanly, so both exemptions are gone rather than kept "just in case". The meta-test now pins the ruff ignore list next to the existing bandit one -- keeping them in separate tests is what let them drift apart. Mutation-verified: restoring the global S603/S607 ignore reds the new guard; removing a publish workflow's concurrency block reds its own parametrized case. 2374 passed, 7 skipped; lint, typecheck and security all green (bandit now also scans `scripts/`). Co-authored-by: Claude Opus 5 (1M context) * fix(sec): redact handler_kwargs on the dump path (#252) (#301) Found by re-running the acceptance checks against merged `dev` before closing #252, rather than assuming the earlier fixes covered it: config leaks: {'repr': False, 'str': False, 'dump': True, 'json': True} dump handler_kwargs: {'password': 'pw123'} #297 made `ClientConfig.__str__` sweep untyped bags, but only `__str__`. `model_dump()` and `model_dump_json()` still emitted `LogHandlerConfig.handler_kwargs` verbatim -- and that is a bare `dict[str, Any]` handed straight to a logging handler, so a `SysLogHandler` password or an HTTP handler's credentials live there. The credential *fields* were already safe; this was the one untyped bag left on the serialization path. `SecretStr` cannot reach inside a `dict[str, Any]`, so a `field_serializer` closes it -- the same approach already used for `EmbeddingConfig.additional_kwargs`, and for the same reason it is safe there: the only consumer reads the attribute (`config.handler_kwargs.copy()` in `logger.py:522`), so nothing rebuilds the model from its own dump. Checked before writing it, because that is exactly the trap that rules this approach out on `ClientConfig`, where `LangChainConfig.from_env` round-trips through `model_dump()`. Operational kwargs stay readable: `maxBytes` and `backupCount` are asserted present, and the live value is asserted unmutated since `logger.py` splats it into the handler constructor. Mutation-verified: disabling the serializer reds both dump tests. 2390 passed, 7 skipped; lint, typecheck and security green. Co-authored-by: Claude Opus 5 (1M context) * feat(e2e): add VCR client-method sweep and fix live schema gaps (#302) Add a local maintainer script that calls every public sync client method under VCR so we can record once and replay while debugging. The first live pass found two model bugs: HolderIndustryBreakdown and CryptoNewsArticle rejected null fields the API actually sends. * docs(changelog): fold Unreleased into 3.0.0 for release cut SecretStr on credential config fields is the public-type break. The 2.6.0-announced arg_model / withdrawn-endpoint removals stay deprecated; they are not in this cut. * docs(changelog): cut 2.7.0, not 3.0.0 3.0 is reserved for the arg_model / EndpointParam.required / withdrawn endpoint removals. In-tree tripwires fail on a ## [3.x] heading until that work lands. SecretStr is a narrow public-type break, same pattern as 2.6.0. * fix(ci): do not SIGPIPE the sdist version-assert after a good build (#305) tar | awk ... exit under set -o pipefail makes GNU tar exit 2 once awk closes the pipe. The 2.7.0 TestPyPI and Dev-Release jobs built the correct wheel then failed, so the release PR never got a TestPyPI comment. Read PKG-INFO in full first. * feat(client): type period, interval, and timeframe as distinct Literals (#306) * feat(client): type period, interval, and timeframe as distinct Literals Replace naked str on those request params with PeriodAnnualQuarter, PeriodFiscal, Period, Interval, and Timeframe. Endpoint valid_values come from the same aliases so the three period contracts cannot drift. The e2e harness samples required closed params from the annotation. * fix(schema): keep closed vocabularies from drifting after 2.7.0 Move the changelog note into Unreleased. Define Period as the union of the two narrower aliases so the wide set cannot diverge, flatten literal_values over unions, and pin fiscal reject-annual plus the leftover enums in tests. * test(schema): pin every fiscal period contract, not one method Walk all fiscal endpoints so annual cannot sneak back onto reports or bulk. Pin sync and async fiscal methods to PeriodFiscal, derive Timeframe from Interval, and stop treating a mocked request error as the income-statement period contract. * feat(schema): derive leftover enums and re-export closed vocabularies (#310) * feat(schema): derive leftover enums and re-export closed vocabularies Unpack ReportingPeriodEnum, IntervalEnum, and IntradayTimeInterval from the Literal tuples. Point TimeInterval at TechnicalInterval and drop the hardcoded alternative interval validator. Re-export Period, PeriodFiscal, PeriodAnnualQuarter, Interval, and Timeframe from fmp_data. Closes #307 Closes #308 Closes #309 * fix(schema): bind leftover period names to the two contracts get_income_statement takes Period. Teach that in the getting-started examples and pin it. Unpack ReportingPeriodEnum from PeriodAnnualQuarter and PeriodFiscal so a union reorder cannot rematch ANNUAL onto FY. Pin leftover name→value maps and TimeInterval rejecting 1day. * docs(changelog): fold closed-vocab typing into 2.7.0 (#312) #306 and #310 landed after the 2.7.0 cut. The release PR is still open, so Unreleased notes move into this minor: Period / Interval / Timeframe Literals, public re-exports, leftover enums derived from the same tuples. * feat(schema): re-export TechnicalInterval from the public API (#313) * feat(schema): re-export TechnicalInterval from the public API Callers can annotate TechnicalClient.interval= from fmp_data instead of fmp_data.schema. Keep it distinct from Timeframe: daily/hourly are aliases, 1day is not. Closes #311 * docs(schema): do not stack TechnicalInterval on Timeframe in examples interval overrides timeframe. Show each contract in its own get_sma call, align the daily/hourly wording with Interval plus those aliases, and pin the README fence. * docs(changelog): keep TechnicalInterval off the wire valid_values list #311 is a client-only leftover alias. Give it its own 2.7.0 Added bullet so "valid_values come from the same aliases" stays about Period / Interval / Timeframe. * fix(review): address #304 release-PR review comments (#314) Loopback HTTP requires a real loopback address. Log extras redact string leaves and Authorization keys. MCP Python manifests accept annotated TOOLS. Redis SCAN MATCH escapes glob metacharacters. The release remote tag is created only after the sdist check. .env loading strips dotenv-style inline comments. * fix(mcp): stop CodeQL clear-text logging on the setup wizard (#315) * fix(mcp): stop CodeQL clear-text logging on the setup wizard Print only the redacted console copy. Do not echo validate_api_key's message — it can quote the key. Suppress the print sinks; the argument is already redacted. * fix(mcp): break getpass taint into the setup wizard console CodeQL still flagged print() after inline suppressions because _redact_sensitive used the getpass-held key and replace() is not a sanitizer. Console output is now pattern-redacted only, writes through stdout, and never interpolates the secret or validator/server messages. * test(mcp): align print-redaction coverage with pattern-only console The setup wizard no longer substitutes the getpass-held key into stdout (CodeQL logging sink). Pin the security test on a key-shaped token instead of a short configured secret. * fix(mcp): stop interpolating exception text in the setup wizard Print only the exception type on crashes so stdout cannot echo a short key. Echo classified key-free validation statuses. Quiet crashes still report. Tests lock the print/held-key contract. * fix(mcp): keep setup-wizard console copy fully static (#321) CodeQL re-flagged stdout.write after the classified-status echo because the helper string is still dataflow from getpass. Print only literals on validation and crash paths. * chore(review): enable CodeRabbit auto-review on PRs targeting dev (#326) Auto-review was default-branch only (main), so every PR into dev was skipped. Add reviews.auto_review.base_branches: ["^dev$"] and keep draft skipping. Closes #318 * fix(security): unify credential display redaction behind two APIs (#328) Move query/assignment/encoded rules into redact_credential_patterns. Prompts use redact_held_secret. Wizard console still runs redact_key_shaped_tokens; those stay off HTTP error bodies so request ids are not blanked. Closes #316 * fix(mcp): classify helper failures, probe FMP, and fix example-profile path (#327) Return classified reasons from validate_api_key and test_mcp_server. fmp-mcp status/test print sink-local literals so a key quoted in child output cannot reach the terminal (#319). validate_api_key constructs a client and calls company.get_quote so a typed junk key no longer reports success. 401 and 403 map to invalid (#317). Point get_manifest_choices and the Claude Desktop example copies at examples/mcp/configurations/ so the wizard offers the shipped profiles (#320). Closes #319 Closes #317 Closes #320 * feat(intelligence): add Senate and House trades-by-id lookups (#323) (#333) Callers can read senate_id on a trade row but could only look up by name. Add get_senate_trades_by_id / get_house_trades_by_id on the stable senate-trades-by-id and house-trades-by-id paths, reusing SenateTrade and HouseDisclosure. Wire query key is senateID. * feat(intelligence): add Senate profile and positions endpoints (#334) * feat(intelligence): add Senate profile and positions endpoints (#324) senateID is the stable member key but the client had no directory. Add get_senate_profile and get_senate_positions with SenateProfile and SenatePosition models. Optional filters are omitted when unset. * fix(intelligence): add catalog hints for senate profile filters Registry validation requires a semantic hint for every endpoint parameter. Missing active/party/position hints failed CI and dropped the new tools from the LangChain registry. Also lock the set-path wire aliases for those filters. * chore: retrigger CI after rebase onto dev The force-push after #333 squash-merged did not start required checks. * feat(intelligence): add Senate net-worth endpoints (#335) * feat(intelligence): add Senate net-worth endpoints (#325) Trade rows do not give the disclosed balance sheet. Add get_senate_net_worth and get_senate_net_worth_aggregated with nested valueRange/debtDetails models. totalsCol is optional: live senate-net-worth-aggregated returns 200 without it. * fix(intelligence): add catalog hint for senate net-worth totalsCol Registry validation requires a semantic hint for every endpoint parameter. Missing totals_col dropped the aggregated endpoint from the LangChain registry. Also lock the set-path wire alias and an aggregated-row parse with extra columns. * chore: retrigger CI after rebase onto dev The force-push after #334 squash-merged did not start required checks. * fix(mcp): treat status-less Invalid API KEY as invalid (#331) * fix(mcp): treat status-less Invalid API KEY as invalid (#329) Live /stable/quote now returns 401 for a junk key, which already maps to AuthenticationError. _check_error_response still raises a bare FMPError with no status_code if a 2xx body is that known copy; classify it as invalid without over-matching unrelated 200 error bodies. * test(mcp): pin live Invalid API KEY copy and trim comments The prefix matcher must keep the longer production body invalid, not only the short issue example. Drop the design-history comment. * fix(sec): redact FMP_API_KEY= assignments on error bodies (#332) * fix(sec): redact FMP_API_KEY= assignments on error bodies (#330) The #316 lookbehind skipped every *_API_KEY= assignment so wizard placeholder copy survived — and so did a real token in an HTTP error body. Match prefixed names, leave placeholder values alone, and keep wizard 32-char heuristics off this path. * fix(sec): allowlist only the wizard [YOUR_API_KEY] placeholder The previous your_*key* / any-[...] skip left bracketed and your_*secret* assignment values unredacted. Keep the exact wizard instruction value and redact everything else. * fix: senate and auth review-wave follow-ups (#336–#340) (#341) * fix: lock senate and auth review-wave follow-ups (#336-#340) Type 2xx Invalid API KEY bodies as AuthenticationError, redact hyphenated assignment names, brand senate_id as SenateId, stop allowing empty trades-by-id e2e results, and require nested net-worth fields on the integration path. Date-family, pagination requiredness, and cassette-contract CI from #336/#338 stay out of this slice. * test: lock review-wave contracts and correct stale comments Drive wizard invalid through _check_error_response, pin hyphenated placeholder survival, and assert trades-by-id kwargs. Soften CHANGELOG claims that overstated the #336 lock. * fix: leftover #336, #338, and #342 slices (#343) * fix: close leftover #336, #338, and #342 slices Type a one-element list Invalid API KEY body as AuthenticationError. Align Senate/House trade dates to date, fill new-model Field descriptions, and move latest pagination to optional_params. Lock government-trading wire aliases in a committed cassette contract, require per-field integration population, and rename value-range min/max so they no longer shadow builtins. * fix: tighten leftover #336, #338, and #342 review locks Stop inventing camelCase in the cassette snapshot so a dropped Field alias fails CI. Type-check integration dates with type() is date, lock list error-key variants without walking multi-row bodies, and reject non-finite net-worth ranges. * fix(intelligence): make leftover RSS pagination optional (#345) (#347) Move SENATE_TRADING_RSS.page, CROWDFUNDING_RSS page/limit, and the adjacent EQUITY_OFFERING_RSS page/limit into optional_params so validate_params({}) applies the documented defaults. Not PaginationArg. * fix(client): type residual list-shaped 2xx error bodies (#344) (#348) Singleton 2xx lists with decorator keys, nested error values, or a scalar Invalid API KEY now raise AuthenticationError. Mixed-key rows that are not the junk-key copy stay on the validation path. Multi-row lists are still not walked. * fix(company): make leftover segmentation/report defaults optional (#349) * docs(spec): leftover mandatory defaults and structure exposure (#349) * docs(plan): leftover company defaults and structure exposure (#349) * feat(schema): add Structure Literal and derive leftover enum * test: lock leftover mandatory defaults on company endpoints * test: count all params in mandatory-default lock floor * fix(company): move leftover structure/period defaults to optional * test(lc): drop stale structure allowlist comment * feat(company): expose structure= on revenue segmentation methods * docs: note leftover company defaults and structure= (#349) * test: lock nested structure forwarding and drop stale comments CodeRabbit is rate-limited; local review found no logic bugs, but structure=nested was only asserted on sync geographic, and two comments still described the pre-#349 catalogue. * docs(lc): drop stale structure example from partition docstring Methods now accept and forward structure=; employee-count limit is the remaining unmapped-omit case. * fix(sec): map timeout and network errors without leaking apikey (#350) TimeoutException and NetworkError never reached the #97 from-None path, so httpx still put apikey= on the request URL in logs, __cause__, and tracebacks. Raise FMPTimeoutError / FMPNetworkError instead, keep those failures retryable, and classify the mapped types in validate_api_key so a transport failure cannot report a valid key. Closes #350 * fix(sec): map leftover RequestError without leaking apikey (#354) (#355) * fix(sec): map leftover RequestError without leaking apikey (#354) Fold remaining httpx.RequestError subclasses into FMPNetworkError with raise from None so ProtocolError, ProxyError, DecodingError, TooManyRedirects, and UnsupportedProtocol cannot put apikey= on __cause__ or error logs. ProtocolError and ProxyError stay retryable; the rest set retryable=False. validate_api_key treats leftover RequestError as unavailable. * test(client): pin leftover RequestError retry counts on async path Document that validate_api_key treats raw non-timeout RequestError as unavailable. Assert ProtocolError uses the configured attempt count (sync and async) and TooManyRedirects is a single attempt on both paths. * feat(schema): re-export Structure from the public fmp_data surface (#352) * feat(schema): re-export Structure from the public fmp_data surface (#352) Promote Structure next to Period / TechnicalInterval so callers can annotate revenue-segmentation structure= from the package root. Flip the #349 negative pin. Endpoint descriptions and STRUCTURE_HINT now say live /stable returns the same list-of-objects for flat and nested. StructureTypeEnum stays leftover / deprecated. * docs: drop stale Structure export sentence from #349 notes Unreleased cannot claim Structure is not re-exported while #352 adds the public export. Leave that claim on the Added entry only. Drop the STRUCTURE_HINT comment that restated the same probe note. * fix(sec): remaining PR 304 review follow-ups (#357) * fix(sec): address remaining release-PR review follow-ups Redact hyphenated `?api-key=` query params and end-of-string `api_key=` tokens. Build LangChain validation envelopes from the already-redacted message. Decode escaped quotes in Makefile dotenv export. Make Claude Desktop JSON samples valid. Mark `senate_trading_rss` as withdrawn from DEFAULT_TOOLS. Fold Unreleased notes into 2.7.0 so the cut matches `dev`. * fix(docs): pin review follow-up tests and complete 2.7.0 upgrade table Expand the 2.7.0 breaking-change table for Senate date and net-worth range types. Make troubleshooting copy-paste JSON valid and keep teaching snippets out of json fences. Pin hyphenated URL redaction, dotenv escapes, validation-envelope field errors, and workflow-level GH_TOKEN absence. Relabel withdrawn intelligence rows that were still marked Deprecated. * chore(ci): bump GitHub Actions pins to latest stable (#368) Combine Dependabot #364–#366 into one SHA-pinned bump: - github/codeql-action 4.37.6 → 4.37.7 (bundle 2.26.3) - astral-sh/setup-uv 10.0.0 → 10.0.1 (transient manifest timeouts) - anthropics/claude-code-action 1.0.191 → 1.0.195 uv remains 0.12.3. CodeQL init/analyze stay on the same SHA. * fix: 2.7.0 leftovers for sync here-docs, async retries, changelog, and withdrawn rows (#369) * fix: land 2.7.0 leftovers for sync here-docs, retries, changelog, and withdrawn rows Sync-Main-to-Dev here-doc terminators now sit at the run: | base indent so bash closes them after YAML strip (#363). Actions shell checks runs bash -n on every workflow run: block. Async retry tests patch asyncio.sleep instead of tenacity.nap.sleep (#358). 2.7.0 changelog has one Added/Changed/Fixed/Security with FMP API surface first (#360). Published GitHub Release notes are unchanged. Every WITHDRAWN_TOOLS catalog row is marked in docs/mcp/tools.md (#361). #359 is deferred: plugin_marketplaces stays a documented gap. * test: pin dated changelog headers and indented here-doc detection The #360 uniqueness regex only matched `## [2.7.0]` with nothing after the bracket, so every dated Keep a Changelog heading was skipped and only Unreleased was gated. Match the optional ` - date` suffix and allow empty Unreleased as the post-cut bucket. The #363 indented-EOF diagnostic compared the unstripped line to the token twice, so space-prefixed terminators were reported as unclosed instead. Treat a whitespace-stripped match as indented. * test: pin Sync-Main-to-Dev here-docs after YAML strip The filename-exists check stayed green if the workflow kept any run: block after the here-docs were deleted. Assert a here-doc survives yaml.safe_load and that EOF is at column 0. * fix(ci): skip identical-tree Release-PR slots (#375) (#376) After a squash onto main and a history-only Sync-Main-to-Dev merge, dev is commits-ahead of main with the same tree. SHA and rev-list checks missed that, so Release-PR opened unlabeled empty slots (#374). Squash-merging those recreates the #202 ancestry break. Treat git diff --quiet origin/dev origin/main as nothing-to-release, convert an already-open empty slot to draft, and restore only PRs stamped with the empty-tree HTML marker. * fix(tests): patch asyncio.sleep in leftover async retry path (#372) Leftover of #358 / #369. Patch asyncio.sleep in test_base_async retry path and assert two awaits on fail-twice-then-succeed. Closes #372. * fix(release): generate GitHub Release notes from CHANGELOG (#370) Extract the matching ## [X.Y.Z] CHANGELOG section for GitHub Release notes instead of the squash commit list. The published v2.7.0 body was rewritten to match. Closes #370. * chore(ci): drop Claude Code GitHub Actions (#359) (#381) * chore(ci): drop Claude Code GitHub Actions (#359) Remove claude-code-review.yml (unpinned plugin_marketplaces fetch of anthropics/claude-code HEAD) and claude.yml (@claude mention agent with contents: write). Required checks stay in ci.yml. Claude Desktop MCP setup is unchanged. * docs(tests): drop stale two-job persist-credentials wording (#359) ALLOWED_TO_PERSIST now only names sync-main-to-dev after the Claude Code Actions removal. The module docstring still said two jobs push. * test(ci): pin Claude Action absence across yaml and composites (#359) GitHub runs .yaml as well as .yml. Scan .github/** for the first-party action, OAuth secret, plugin_marketplaces, and the unpinned marketplace URL so a rename or vendored wrapper cannot restore the dropped jobs. * fix(release): generate CHANGELOG notes before tagging (#370) (#382) A missing ## [X.Y.Z] heading failed after the annotated tag was already on GitHub, leaving a tag with no Release or PyPI artifact. Extract notes immediately after version calculation. Pin argv/env and step order. Docs no longer describe git-log autogen. * fix(release): address CodeRabbit leftovers on CHANGELOG notes (#370) (#385) Split version sections only on ## [X.Y.Z] so other level-2 headings stay in the body. CLI write errors exit 1 with error:. Manual docs generate notes before tagging. Async retry test asserts both waits are 4s. --------- Signed-off-by: dependabot[bot] Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Grok Build Co-authored-by: github-actions --- .github/workflows/claude-code-review.yml | 81 ----------- .github/workflows/claude.yml | 74 ---------- .github/workflows/release.yml | 80 +++-------- CHANGELOG.md | 37 ++++- docs/contributing/releasing.md | 90 +++++-------- scripts/github_release_notes.py | 124 +++++++++++++++++ tests/unit/test_base_async.py | 17 ++- tests/unit/test_checkout_credentials.py | 39 ++++-- tests/unit/test_github_release_notes.py | 164 +++++++++++++++++++++++ tests/unit/test_release_tag_push.py | 35 ++++- 10 files changed, 450 insertions(+), 291 deletions(-) delete mode 100644 .github/workflows/claude-code-review.yml delete mode 100644 .github/workflows/claude.yml create mode 100644 scripts/github_release_notes.py create mode 100644 tests/unit/test_github_release_notes.py diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index 0dbb0f12..00000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,81 +0,0 @@ -name: Claude Code Review - -on: - pull_request: - types: [opened, synchronize, ready_for_review, reopened] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" - -jobs: - claude-review: - # Same-repo only. Token presence is checked in a step below — the - # `secrets` context is NOT allowed in job-level `if` (actionlint / - # GitHub validate the workflow as a failed check named after this file - # on every push that touches it). An empty secret previously made the - # action exit in ~400ms with is_error:true / cost 0, painting every PR - # red without posting a review (#184). - if: github.event.pull_request.head.repo.full_name == github.repository - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - issues: write - - steps: - - name: Check whether Claude review is configured - id: auth - env: - TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - run: | - if [ -z "$TOKEN" ]; then - echo "configured=false" >> "$GITHUB_OUTPUT" - echo "Claude Code Review skipped: CLAUDE_CODE_OAUTH_TOKEN is not set." - echo "## Claude Code Review" >> "$GITHUB_STEP_SUMMARY" - echo "Skipped — \`CLAUDE_CODE_OAUTH_TOKEN\` is not configured." >> "$GITHUB_STEP_SUMMARY" - else - echo "configured=true" >> "$GITHUB_OUTPUT" - fi - - - name: Checkout repository - if: steps.auth.outputs.configured == 'true' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - fetch-depth: 1 - - # continue-on-error: an *expired* token still reaches this step (the gate - # only sees "secret is non-empty"), and a broken review must not fail - # the PR. Review is advisory; required checks live in ci.yml. - - name: Run Claude Code Review - if: steps.auth.outputs.configured == 'true' - id: claude-review - continue-on-error: true - uses: anthropics/claude-code-action@d40ddef4c030e508327d6e35a9c45f3368482c50 # v1.0.195 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - # KNOWN GAP, not an oversight (#252). This marketplace resolves to - # whatever HEAD is at run time, and the action's `plugin_marketplaces` - # input takes a bare Git URL with no documented ref/SHA syntax -- so - # unlike every `uses:` in this repo it cannot be pinned, and the - # repository's `sha_pinning_required` setting does not see it because - # the fetch happens at runtime rather than through `uses:`. - # - # Accepted because it is the same first-party origin as the - # SHA-pinned action above, and this job is review-only: no checkout - # credentials, and its writes are confined to PR comments. Revisit if - # the action gains a pinning syntax. - plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' - plugins: 'code-review@claude-code-plugins' - prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index 3418d6f1..00000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Claude Code - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - issues: - types: [opened, assigned] - pull_request_review: - types: [submitted] - -# One agent per issue/PR at a time. Two @claude mentions in quick succession -# otherwise race each other in the same workspace, both holding contents:write. -concurrency: - group: claude-${{ github.event.issue.number || github.event.pull_request.number || github.ref }} - cancel-in-progress: false - -jobs: - claude: - if: | - (github.event_name == 'issue_comment' && - contains(github.event.comment.body, '@claude') && - contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.comment.author_association)) || - (github.event_name == 'pull_request_review_comment' && - contains(github.event.comment.body, '@claude') && - contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.comment.author_association)) || - (github.event_name == 'pull_request_review' && - contains(github.event.review.body, '@claude') && - contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.review.author_association)) || - (github.event_name == 'issues' && - (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) && - contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.issue.author_association)) - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - issues: write - actions: read # Required for Claude to read CI results on PRs - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 1 - # persist-credentials is deliberately left on here, unlike every - # other checkout in this repo (#252). claude-code-action documents - # `use_commit_signing: false` (its default) as "Claude uses standard - # git commands", so it commits and pushes through the ambient git - # credential. Removing it would silently break @claude's ability to - # push, which is the point of this workflow. - # - # The residual risk is real and accepted: this job holds - # `contents: write` while running a model-driven agent over issue - # bodies, PR diffs and CI logs that are not author-gated (only the - # *trigger* is). The mitigations are the trigger gate above, the - # concurrency group below, and the SHA-pinned action. - - - name: Run Claude Code - id: claude - uses: anthropics/claude-code-action@d40ddef4c030e508327d6e35a9c45f3368482c50 # v1.0.195 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - - # This is an optional setting that allows Claude to read CI results on PRs - additional_permissions: | - actions: read - - # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. - # prompt: 'Update the pull request description to include a summary of changes.' - - # Optional: Add claude_args to customize behavior and configuration - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - # claude_args: '--allowed-tools Bash(gh pr:*)' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d6b29e7..f71e830e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -134,6 +134,22 @@ jobs: echo "VERSION_NUMBER=$NEW_VERSION" } >> "$GITHUB_OUTPUT" + - name: 📋 Generate release notes from CHANGELOG + env: + RELEASE_TAG: ${{ steps.version.outputs.VERSION }} + RELEASE_VERSION: ${{ steps.version.outputs.VERSION_NUMBER }} + run: | + set -euo pipefail + # Body is the matching ## [X.Y.Z] section, not the squash commit + # list. Missing / empty section fails the job *before* the tag is + # created or pushed, so a missing heading cannot leave a remote + # tag with no GitHub Release / PyPI artifact (#370 leftover). + python3 scripts/github_release_notes.py \ + --changelog CHANGELOG.md \ + --version "$RELEASE_VERSION" \ + --tag "$RELEASE_TAG" \ + --out release-notes.md + - name: 🏷️ Create local tag run: | set -euo pipefail @@ -233,73 +249,17 @@ jobs: gh api "repos/${REPO}/git/ref/tags/${RELEASE_TAG}" > /dev/null echo "Pushed tag ${RELEASE_TAG} -> ${COMMIT_SHA}" - - name: 📋 Generate release notes from PR - id: release_notes - env: - # Untrusted PR fields via env to avoid script injection. - PR_TITLE: ${{ github.event.pull_request.title }} - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_AUTHOR: ${{ github.event.pull_request.user.login }} - PR_BRANCH: ${{ github.event.pull_request.head.ref }} - run: | - set -euo pipefail - - if [[ "$PR_BRANCH" =~ ^hotfix-.* ]]; then - RELEASE_TYPE="🔥 Hotfix" - else - RELEASE_TYPE="🚀 Release" - fi - - # Get commits since last *previous* tag (HEAD is the new tag tip) - PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") - if [ -z "$PREV_TAG" ]; then - COMMIT_NOTES="Initial release" - else - COMMIT_NOTES=$(git log --pretty=format:"- %s" "$PREV_TAG"..HEAD) - fi - - # Random per-run delimiter. A fixed `EOF` is only safe here by - # accident: `--pretty=format:"- %s"` prefixes every commit line, so a - # commit subject of literally `EOF` emits `- EOF` and cannot close - # the heredoc. Drop the `- ` prefix and it becomes a live injection -- - # every following line would be parsed by the runner as a workflow - # command. Verified both ways; the guard should not be a side effect - # of a format string (#252). - DELIM="NOTES_EOF_$(openssl rand -hex 16)" - { - echo "NOTES<<$DELIM" - echo "### $RELEASE_TYPE" - echo "" - echo "**$PR_TITLE** (#$PR_NUMBER) by @$PR_AUTHOR" - echo "" - echo "### 📝 Commits" - echo "$COMMIT_NOTES" - echo "$DELIM" - } >> "$GITHUB_OUTPUT" - - name: 🚀 Create GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_TAG: ${{ steps.version.outputs.VERSION }} - RELEASE_VERSION: ${{ steps.version.outputs.VERSION_NUMBER }} - RELEASE_NOTES: ${{ steps.release_notes.outputs.NOTES }} run: | set -euo pipefail - { - echo "## 🎉 Release ${RELEASE_TAG}" - echo "" - echo "${RELEASE_NOTES}" - echo "" - echo "### 📦 Installation" - echo '```bash' - echo "pip install fmp-data==${RELEASE_VERSION}" - echo '```' - echo "" - echo "### 🔗 Links" - echo "- [PyPI Package](https://pypi.org/project/fmp-data/${RELEASE_VERSION}/)" - echo "- [Documentation](https://mehdizare.github.io/fmp-data/)" - } > release-notes.md + if [ ! -s release-notes.md ]; then + echo "::error::release-notes.md is missing or empty." + exit 1 + fi if gh release view "$RELEASE_TAG" > /dev/null 2>&1; then echo "::error::GitHub Release $RELEASE_TAG already exists. Refusing to overwrite." diff --git a/CHANGELOG.md b/CHANGELOG.md index efa42650..ed9ec1bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,20 +11,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **GitHub Actions pins.** `github/codeql-action` 4.37.6 → 4.37.7 (default bundle 2.26.3). `astral-sh/setup-uv` 10.0.0 → 10.0.1 - (tolerate transient manifest timeouts). `anthropics/claude-code-action` - 1.0.191 → 1.0.195. All remain 40-char SHA pins. The installed uv - floor is unchanged (`0.12.3`). + (tolerate transient manifest timeouts). All remain 40-char SHA pins. + The installed uv floor is unchanged (`0.12.3`). - **2.7.0 changelog subsections are unique (#360).** One Added / Changed / Fixed / Security under the version heading. `### FMP API surface` sits immediately under the intro. The uniqueness tripwire matches dated - `## [X.Y.Z] - date` headings; empty Unreleased stays valid. The - published GitHub Release notes are unchanged (they never used this - file's body). + `## [X.Y.Z] - date` headings; empty Unreleased stays valid. +- **GitHub Release notes come from CHANGELOG.md (#370).** `release.yml` + runs `scripts/github_release_notes.py` to extract the matching + `## [X.Y.Z]` section (fail-closed if missing or empty) and wrap it + with install / docs links. The published `v2.7.0` notes were rewritten + to that body. They are no longer the squash commit stub. - **Withdrawn MCP catalog rows are marked in `docs/mcp/tools.md` (#361).** Every `WITHDRAWN_TOOLS` spec now carries **Withdrawn / not in DEFAULT_TOOLS** (with successor or none). Docs-sync fails if a withdrawn row omits the mark. +### Removed + +- **Claude Code GitHub Actions (#359).** Dropped `claude-code-review.yml` + (automatic PR review that cloned `anthropics/claude-code` at HEAD) and + `claude.yml` (`@claude` mention agent with `contents: write`). Required + checks stay in `ci.yml`. Claude Desktop MCP setup is unchanged. Delete + the `CLAUDE_CODE_OAUTH_TOKEN` repository secret after this reaches + `main`. + ### Fixed - **Sync-Main-to-Dev here-docs compile under `run: |` (#363).** YAML @@ -36,12 +47,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Async retry tests patch `asyncio.sleep` (#358).** Tenacity 9.1.4 async retries go through `_portable_async_sleep` → `asyncio.sleep`, not `tenacity.nap.sleep`. Sync tests keep the nap patch. +- **Leftover async retry path in `test_base_async.py` patches + `asyncio.sleep` (#372).** + `test_request_async_with_retry_on_transient_failure` (`max_retries=3`) + no longer waits the real Tenacity 4–10s backoff. It asserts two + `asyncio.sleep` awaits on the fail-twice-then-succeed path. - **Release-PR skips identical-tree standing slots (#375).** After a squash + history-only sync, `dev` is commits-ahead of `main` with the same tree. The job now treats `git diff --quiet origin/dev origin/main` as nothing-to-release, converts an already-open empty slot to draft, and restores it when a content delta appears. Opening an unlabeled empty squash recreated the #202 ancestry break (#374). +- **CHANGELOG notes generate before tagging (#370 leftover).** A missing + `## [X.Y.Z]` used to fail after the remote tag existed, leaving a tag + with no GitHub Release or PyPI artifact. The extract step now runs + immediately after version calculation. +- **Release-note extractor keeps non-version ``##`` headings (#370).** + Split is only on ``## [X.Y.Z]`` so a section body can contain other + level-2 headings. CLI write failures print `error:` and exit 1. + Manual release docs generate notes before pushing the tag. Async + retry test asserts both Tenacity waits are 4s. ## [2.7.0] - 2026-08-19 diff --git a/docs/contributing/releasing.md b/docs/contributing/releasing.md index 7a1ca021..70fcf618 100644 --- a/docs/contributing/releasing.md +++ b/docs/contributing/releasing.md @@ -130,14 +130,14 @@ the overlay in a hotfix-shaped PR. `contents: write` build job, then publishes to PyPI from a second job that only downloads the hashed artifacts and has `id-token: write` (environment `pypi`). Existing tags / releases / PyPI versions fail the job instead of - being skipped. + being skipped. Release notes are the matching `## [X.Y.Z]` section of + `CHANGELOG.md` (via `scripts/github_release_notes.py`), not the squash + commit list (#370). A missing or empty section fails the job. - External Actions are pinned to full commit SHAs. The PEP 517 frontend (`build`) and backend (`hatchling`, `hatch-vcs`) are installed from version floors in `.github/requirements-build.txt` (not a hashed lock). Publish jobs run `python -m build --no-isolation` so isolation cannot pull a different backend than the one just installed. -- **Claude Code Review** is advisory: missing or expired OAuth tokens do not - fail the PR. Required gates live in `ci.yml` / the branch rulesets. ### Secrets used by release automation @@ -164,7 +164,8 @@ the overlay in a hotfix-shaped PR. 2. **Label Detection**: Action reads PR labels to determine version bump 3. **Version Calculation**: New version is calculated based on current version + bump type 4. **Git Tagging**: New git tag is created with the version -5. **Release Creation**: GitHub release is created with auto-generated notes +5. **Release Creation**: GitHub release notes are the matching + `## [X.Y.Z]` CHANGELOG section (`scripts/github_release_notes.py`) 6. **PyPI Publishing**: Package is built and published to PyPI 7. **History sync**: Sync-Main-to-Dev restores `main` as an ancestor of `dev` @@ -240,11 +241,16 @@ For emergency releases or when automation fails: - Add exactly one version label: `release:major`, `release:minor`, or `release:patch` - Include release notes in description -7. **Merge and Tag** +7. **Merge, generate notes, then tag** ```bash # After PR approval and merge git checkout main git pull origin main + python3 scripts/github_release_notes.py \ + --changelog CHANGELOG.md \ + --version 1.2.3 \ + --tag v1.2.3 \ + --out release-notes.md git tag v1.2.3 git push origin v1.2.3 ``` @@ -256,10 +262,15 @@ For emergency releases or when automation fails: ``` 9. **Create GitHub Release** - - Go to GitHub Releases - - Create release from tag - - Add release notes - - Publish release + - Happy path: `release.yml` extracts `## [X.Y.Z]` via + `scripts/github_release_notes.py` (fail-closed, before tagging). + - If automation failed, use the file from step 7: + ```bash + gh release create v1.2.3 --notes-file release-notes.md + ``` + - Fold `## Unreleased` into `## [X.Y.Z] - date` *before* adding a + `release:*` label. A heading that exists only under Unreleased + fails the job. Preview with `--version X.Y.Z`. ## Pre-release Process @@ -300,59 +311,22 @@ pip install --pre fmp-data ## Release Notes -### Automated Generation +GitHub Release notes are the matching `## [X.Y.Z]` section of +`CHANGELOG.md`, wrapped with install / docs links by +`scripts/github_release_notes.py`. They are **not** the squash commit +list, PR titles, or GitHub autogen. -Release notes are automatically generated from: -- PR titles and descriptions -- Commit messages -- Issue references -- Breaking change callouts +Before adding a `release:*` label, fold `## Unreleased` into +`## [X.Y.Z] - YYYY-MM-DD` for the version that label will produce. +Preview: -### Manual Enhancement - -Enhance auto-generated notes with: -- **Overview**: High-level summary of changes -- **Highlights**: Key new features or improvements -- **Breaking Changes**: Required user actions -- **Migration Guide**: How to upgrade from previous version -- **Contributors**: Thank contributors - -### Example Release Notes - -```markdown -# v1.2.0 - Enhanced Market Data Support - -## Overview -This release adds comprehensive market intelligence features and improves error handling across all clients. - -## ✨ New Features -- Market Intelligence client with sentiment analysis -- Enhanced company search with filtering options -- Async support for all fundamental data endpoints - -## 🐛 Bug Fixes -- Fixed rate limiting calculation for concurrent requests -- Resolved memory leak in async client cleanup -- Improved error messages for invalid API responses - -## 💥 Breaking Changes -- `MarketClient.get_quotes()` now returns `List[Quote]` instead of `Dict` -- Minimum Python version increased to 3.10 - -## 📖 Documentation -- Added comprehensive API reference -- Updated getting started guide -- New examples for market intelligence features - -## 🏗️ Internal Changes -- Upgraded to Pydantic v2 -- Improved test coverage to 95% -- Enhanced CI/CD pipeline - -## Contributors -Thanks to @contributor1, @contributor2, and @contributor3 for their contributions! +```bash +python3 scripts/github_release_notes.py --version X.Y.Z --tag vX.Y.Z ``` +A missing or empty section fails the release job before the tag is +created. + ## Version Strategy ### Major Releases (X.0.0) diff --git a/scripts/github_release_notes.py b/scripts/github_release_notes.py new file mode 100644 index 00000000..8d2b68cf --- /dev/null +++ b/scripts/github_release_notes.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Build GitHub Release notes from a Keep-a-Changelog version section (#370). + +The published GitHub Release used to be the squash-commit stub that +``release.yml`` generated from ``git log``. That body is not +``CHANGELOG.md``. This script extracts ``## [X.Y.Z]`` (minus the heading +and minus Unreleased / other versions) and wraps it with the install / +docs footer the Release page already carried. + +Stdlib only: do not import ``fmp_data``. The release job fails closed +on a missing or empty section *before* it creates or pushes the tag. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import re +import sys + +_VERSION_HEADING = re.compile(r"^## \[([^\]]+)\](?:\s+-.*)?\s*$") +# Split only on dated/undated Keep-a-Changelog version headings so a +# non-version ``## `` inside a section stays in that section's body. +_VERSION_CHUNK_START = re.compile(r"(?=^## \[[^\]]+\](?:\s+-.*)?\s*$)", re.M) + + +def normalize_version(version: str) -> str: + """Strip a leading ``v`` so ``v2.7.0`` and ``2.7.0`` match the heading.""" + return version[1:] if version.startswith("v") else version + + +def extract_section(text: str, version: str) -> str: + """Return the body of ``## [version]`` (no heading). + + Raises: + ValueError: the heading is missing, or the section has no body. + """ + wanted = normalize_version(version) + chunks = _VERSION_CHUNK_START.split(text) + for chunk in chunks: + lines = chunk.splitlines() + if not lines: + continue + match = _VERSION_HEADING.match(lines[0].strip()) + if match is None or match.group(1) != wanted: + continue + body = "\n".join(lines[1:]).strip() + if not body: + raise ValueError(f"CHANGELOG section ## [{wanted}] is empty") + return body + raise ValueError(f"CHANGELOG has no ## [{wanted}] section") + + +def render_github_release(*, tag: str, version: str, body: str) -> str: + """Wrap a changelog section with the GitHub Release title and footer.""" + number = normalize_version(version) + release_tag = tag if tag.startswith("v") else f"v{tag}" + return "\n".join( + ( + f"## 🎉 Release {release_tag}", + "", + body, + "", + "### 📦 Installation", + "```bash", + f"pip install fmp-data=={number}", + "```", + "", + "### 🔗 Links", + f"- [PyPI Package](https://pypi.org/project/fmp-data/{number}/)", + "- [Documentation](https://mehdizare.github.io/fmp-data/)", + "", + ) + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--changelog", + type=Path, + default=Path("CHANGELOG.md"), + help="Path to CHANGELOG.md (default: ./CHANGELOG.md)", + ) + parser.add_argument( + "--version", + required=True, + help="Version to extract, with or without a leading v", + ) + parser.add_argument( + "--tag", + default="", + help="Git tag for the Release title (default: v + version)", + ) + parser.add_argument( + "--out", + type=Path, + default=None, + help="Write notes here. Default: stdout", + ) + args = parser.parse_args(argv) + + try: + text = args.changelog.read_text(encoding="utf-8") + body = extract_section(text, args.version) + except (OSError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + tag = args.tag or f"v{normalize_version(args.version)}" + rendered = render_github_release(tag=tag, version=args.version, body=body) + if args.out is None: + sys.stdout.write(rendered) + return 0 + try: + args.out.write_text(rendered, encoding="utf-8") + except OSError as exc: + print(f"error: cannot write {args.out}: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/unit/test_base_async.py b/tests/unit/test_base_async.py index dfc1384e..f4aded02 100644 --- a/tests/unit/test_base_async.py +++ b/tests/unit/test_base_async.py @@ -271,7 +271,11 @@ class TestAsyncRetry: async def test_request_async_with_retry_on_transient_failure( self, client_config, sample_endpoint ): - """Test that async request retries on transient failures.""" + """Test that async request retries on transient failures. + + Patches ``asyncio.sleep`` so the Tenacity 4-10s backoff is not a + real wait (#372 leftover of #358). + """ from fmp_data.base import BaseClient client = BaseClient(client_config) @@ -292,7 +296,10 @@ async def mock_request(*_args, **_kwargs): raise httpx.TimeoutException("Timeout") return mock_response - with patch.object(client, "_setup_async_client") as mock_setup: + with ( + patch.object(client, "_setup_async_client") as mock_setup, + patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep, + ): mock_async_client = AsyncMock() mock_async_client.request = mock_request mock_setup.return_value = mock_async_client @@ -302,6 +309,12 @@ async def mock_request(*_args, **_kwargs): # Should have been called 3 times (2 failures + 1 success) assert call_count == 3 assert result == {"test": "data"} + # Two backoffs between the three attempts. Tenacity 9.1.4 async + # retries go through asyncio.sleep, not tenacity.nap.sleep (#372). + # wait_exponential(multiplier=1, min=4, max=10) clamps both + # waits to 4s (2^0 and 2^1 are below min). + assert mock_sleep.await_count == 2 + assert [call.args[0] for call in mock_sleep.await_args_list] == [4, 4] await client.aclose() diff --git a/tests/unit/test_checkout_credentials.py b/tests/unit/test_checkout_credentials.py index 27e16155..c704f7b6 100644 --- a/tests/unit/test_checkout_credentials.py +++ b/tests/unit/test_checkout_credentials.py @@ -2,11 +2,11 @@ ``actions/checkout`` writes the job's token into ``.git/config`` unless told not to, where every later step can read it -- including dependency installs, -build backends and third-party actions. Only two jobs in this repo actually -push, so the rest have no reason to carry the credential at all. +build backends and third-party actions. Only ``sync-main-to-dev`` still +pushes, so every other checkout drops the credential. -The exceptions are asserted by name rather than skipped, so adding a third one -is a deliberate edit to this list and not an accident. +The remaining exception is asserted by name rather than skipped, so adding +another one is a deliberate edit to this list and not an accident. """ from __future__ import annotations @@ -18,14 +18,14 @@ import yaml REPO_ROOT = Path(__file__).resolve().parents[2] -WORKFLOWS = sorted((REPO_ROOT / ".github" / "workflows").glob("*.yml")) +WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows" +WORKFLOWS = sorted(WORKFLOWS_DIR.glob("*.yml")) + sorted(WORKFLOWS_DIR.glob("*.yaml")) +GITHUB_YAML = sorted((REPO_ROOT / ".github").rglob("*.yml")) + sorted( + (REPO_ROOT / ".github").rglob("*.yaml") +) # job name -> why this one keeps its credential. ALLOWED_TO_PERSIST = { - "claude": ( - "claude-code-action runs standard git commands to commit and push; " - "dropping the credential silently breaks @claude" - ), "sync-main-to-dev": "really runs `git push origin HEAD:refs/heads/...`", } @@ -80,3 +80,24 @@ def test_read_only_checkouts_do_not_pass_an_explicit_token() -> None: if "token" in (step.get("with") or {}) and job not in ALLOWED_TO_PERSIST ] assert not offenders, offenders + + +def test_claude_code_action_workflows_are_absent() -> None: + """#359: do not restore the unpinned marketplace review or @claude agent. + + GitHub runs ``.yml`` and ``.yaml``. Composite actions under + ``.github/actions/`` are scanned too, so a vendored wrapper cannot + hide ``claude-code-action`` / ``plugin_marketplaces``. + """ + for name in ( + "claude.yml", + "claude.yaml", + "claude-code-review.yml", + "claude-code-review.yaml", + ): + assert not (WORKFLOWS_DIR / name).exists() + remaining = "\n".join(path.read_text(encoding="utf-8") for path in GITHUB_YAML) + assert "anthropics/claude-code-action" not in remaining + assert "CLAUDE_CODE_OAUTH_TOKEN" not in remaining + assert "plugin_marketplaces" not in remaining + assert "anthropics/claude-code.git" not in remaining diff --git a/tests/unit/test_github_release_notes.py b/tests/unit/test_github_release_notes.py new file mode 100644 index 00000000..bcc8dcae --- /dev/null +++ b/tests/unit/test_github_release_notes.py @@ -0,0 +1,164 @@ +"""GitHub Release notes come from CHANGELOG.md, not the squash log (#370).""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "github_release_notes.py" +_CHANGELOG = Path(__file__).resolve().parents[2] / "CHANGELOG.md" +_SPEC = importlib.util.spec_from_file_location("github_release_notes", _SCRIPT) +assert _SPEC and _SPEC.loader +notes = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(notes) + +_SAMPLE = """# Changelog + +## Unreleased + +### Added + +- unreleased note + +## [2.7.0] - 2026-08-19 + +Released from `dev`. + +### FMP API surface (scan this first) + +- diluted P/E + +### Added + +- senate trades + +## [2.6.0] - 2026-08-10 + +### Added + +- old note +""" + + +def test_extract_section_drops_heading_and_neighbors() -> None: + body = notes.extract_section(_SAMPLE, "2.7.0") + assert body.startswith("Released from `dev`.") + assert "### FMP API surface (scan this first)" in body + assert "### Added" in body + assert "senate trades" in body + assert "## [2.7.0]" not in body + assert "unreleased note" not in body + assert "old note" not in body + assert "## Unreleased" not in body + assert "## [2.6.0]" not in body + + +def test_extract_section_strips_v_prefix() -> None: + assert notes.extract_section(_SAMPLE, "v2.7.0") == notes.extract_section( + _SAMPLE, "2.7.0" + ) + + +def test_extract_section_missing_version_raises() -> None: + with pytest.raises(ValueError, match=r"no ## \[2\.8\.0\] section"): + notes.extract_section(_SAMPLE, "2.8.0") + + +def test_extract_section_keeps_non_version_level_two_heading() -> None: + text = ( + "## [2.7.0] - 2026-08-19\n\n" + "intro\n\n" + "## Upgrade notes\n\n" + "keep me\n\n" + "## [2.6.0] - 2026-08-10\n\n" + "old\n" + ) + body = notes.extract_section(text, "2.7.0") + assert "intro" in body + assert "## Upgrade notes" in body + assert "keep me" in body + assert "old" not in body + + +def test_extract_section_empty_body_raises() -> None: + text = "## [2.7.1] - 2026-08-20\n\n## [2.7.0] - 2026-08-19\n\nbody\n" + with pytest.raises(ValueError, match=r"## \[2\.7\.1\] is empty"): + notes.extract_section(text, "2.7.1") + + +def test_render_github_release_wraps_install_and_links() -> None: + rendered = notes.render_github_release( + tag="v2.7.0", version="2.7.0", body="Released from `dev`." + ) + assert rendered.startswith("## 🎉 Release v2.7.0\n") + assert "Released from `dev`." in rendered + assert "pip install fmp-data==2.7.0" in rendered + assert "https://pypi.org/project/fmp-data/2.7.0/" in rendered + assert "https://mehdizare.github.io/fmp-data/" in rendered + assert "### 📝 Commits" not in rendered + + +def test_live_changelog_2_7_0_extracts_fmp_surface_first() -> None: + body = notes.extract_section(_CHANGELOG.read_text(encoding="utf-8"), "2.7.0") + assert "### FMP API surface (scan this first)" in body + assert body.index("### FMP API surface") < body.index("### Added") + assert "## Unreleased" not in body + assert "## [2.6.0]" not in body + + +def test_cli_writes_release_file(tmp_path: Path) -> None: + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text(_SAMPLE, encoding="utf-8") + out = tmp_path / "release-notes.md" + rc = notes.main( + [ + "--changelog", + str(changelog), + "--version", + "2.7.0", + "--tag", + "v2.7.0", + "--out", + str(out), + ] + ) + assert rc == 0 + body = notes.extract_section(_SAMPLE, "2.7.0") + expected = notes.render_github_release(tag="v2.7.0", version="2.7.0", body=body) + assert out.read_text(encoding="utf-8") == expected + assert "unreleased note" not in expected + assert "old note" not in expected + + +def test_cli_missing_section_is_nonzero(tmp_path: Path) -> None: + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text(_SAMPLE, encoding="utf-8") + rc = notes.main( + [ + "--changelog", + str(changelog), + "--version", + "9.9.9", + "--out", + str(tmp_path / "x.md"), + ] + ) + assert rc == 1 + + +def test_cli_unwritable_out_is_nonzero(tmp_path: Path) -> None: + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text(_SAMPLE, encoding="utf-8") + rc = notes.main( + [ + "--changelog", + str(changelog), + "--version", + "2.7.0", + "--out", + str(tmp_path), + ] + ) + assert rc == 1 diff --git a/tests/unit/test_release_tag_push.py b/tests/unit/test_release_tag_push.py index e4ac29e9..f4fcbe8d 100644 --- a/tests/unit/test_release_tag_push.py +++ b/tests/unit/test_release_tag_push.py @@ -116,8 +116,41 @@ def test_the_push_is_verified_before_downstream_steps_rely_on_it() -> None: assert "git/ref/tags/" in _step("Push tag")["run"] -@pytest.mark.parametrize("fragment", ["Push tag", "Create GitHub Release"]) +@pytest.mark.parametrize( + "fragment", + ["Push tag", "Create GitHub Release", "Generate release notes from CHANGELOG"], +) def test_token_scoped_steps_still_fail_closed(fragment: str) -> None: run = _step(fragment)["run"] assert "set -euo pipefail" in run assert "|| true" not in run + + +def test_github_release_notes_come_from_changelog() -> None: + """#370: notes are CHANGELOG.md, not the squash commit list.""" + generate = _step("Generate release notes from CHANGELOG") + run = _code_lines(generate["run"]) + assert "scripts/github_release_notes.py" in run + assert "--changelog CHANGELOG.md" in run + assert '--version "$RELEASE_VERSION"' in run + assert '--tag "$RELEASE_TAG"' in run + assert "--out release-notes.md" in run + assert "git log" not in run + assert "GITHUB_OUTPUT" not in run + env = generate.get("env") or {} + assert "steps.version.outputs.VERSION_NUMBER" in str(env.get("RELEASE_VERSION")) + assert "steps.version.outputs.VERSION" in str(env.get("RELEASE_TAG")) + + create = _step("Create GitHub Release") + create_run = create["run"] + assert "--notes-file release-notes.md" in create_run + assert "! -s release-notes.md" in create_run + assert "RELEASE_NOTES" not in (create.get("env") or {}) + assert "git log" not in _code_lines(create_run) + + +def test_changelog_notes_are_generated_before_tagging() -> None: + """A missing ## [X.Y.Z] must fail before a remote tag exists.""" + assert _index("Generate release notes") < _index("Create local tag") + assert _index("Generate release notes") < _index("Push tag") + assert _index("Generate release notes") < _index("Create GitHub Release")