get_version_history
Retrieve version history of eCFR sections, subparts, or parts dating back to 2017. Identifies substantive regulatory changes versus editorial updates.
Instructions
Get the version history of a CFR section, subpart, or part.
Returns a list of content versions with dates, amendment info, and whether each version was a substantive text change vs editorial.
The 'substantive' field is key: True = the regulatory text actually changed. False = only editorial/formatting change.
History goes back to January 2017 only. Pre-2017 changes are not tracked.
part/subpart/section accept int or string.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| title_number | No | ||
| part | No | ||
| section | No | ||
| subpart | No |
Output Schema
| Name | Required | Description | Default |
|---|---|---|---|
No arguments | |||
Implementation Reference
- The main handler for the 'get_version_history' tool. It validates inputs via _validate_title_number and _coerce_cfr_str, builds the API path /api/versioner/v1/versions/title-{title_number}, and calls _get_json to fetch version history data from the eCFR API.
@mcp.tool(annotations={"title": "Get Version History", "readOnlyHint": True, "destructiveHint": False}) async def get_version_history( title_number: int = 48, part: Any = None, section: Any = None, subpart: Any = None, ) -> dict[str, Any]: """Get the version history of a CFR section, subpart, or part. Returns a list of content versions with dates, amendment info, and whether each version was a substantive text change vs editorial. The 'substantive' field is key: True = the regulatory text actually changed. False = only editorial/formatting change. History goes back to January 2017 only. Pre-2017 changes are not tracked. part/subpart/section accept int or string. """ title_number = _validate_title_number(title_number) part = _coerce_cfr_str(part, field="part", strip_prefixes=True) section = _coerce_cfr_str(section, field="section", strip_prefixes=True) subpart = _coerce_cfr_str(subpart, field="subpart", strip_prefixes=True) if not any((part, section, subpart)): raise ValueError( "get_version_history requires at least one of: part, subpart, section." ) path = f"/api/versioner/v1/versions/title-{title_number}" params: dict[str, str] = {} if part: params["part"] = part if section: params["section"] = section if subpart: params["subpart"] = subpart return await _get_json(path, params) - servers/ecfr-mcp/src/ecfr_mcp/server.py:677-677 (registration)The tool is registered as an MCP tool via the @mcp.tool decorator on the async function, with annotations marking it as read-only and non-destructive.
@mcp.tool(annotations={"title": "Get Version History", "readOnlyHint": True, "destructiveHint": False}) - Helper function that coerces CFR identifiers (part/section/subpart) from int or string to a cleaned string, optionally stripping common prefixes like 'FAR ' or '48 CFR '.
def _coerce_cfr_str( value: Any, *, field: str, strip_prefixes: bool = False, maxlen: int = 120, ) -> str | None: """Accept int or str for CFR identifiers (part/subpart/section/chapter). LLMs often pass ints (part=15). We coerce to str, strip whitespace, and optionally strip common user-added prefixes like 'FAR ' or '48 CFR '. Returns None for None/empty/whitespace-only. Raises on other types. """ - Helper function that validates the title_number parameter is an integer between 1 and 50.
def _validate_title_number(value: Any, *, field: str = "title_number") -> int: """CFR titles are 1-50.""" if value is None: raise ValueError(f"{field} is required.") if isinstance(value, bool): raise ValueError(f"{field} must be an int 1-50, not bool.") try: n = int(value) except (TypeError, ValueError, OverflowError) as exc: # OverflowError catches inf/nan float coercion. Round 6 fix. raise ValueError(f"{field} must be an int 1-50. Got {value!r}.") from exc if n < 1 or n > 50: raise ValueError(f"{field} must be between 1 and 50. Got {n}.") return n - HTTP GET helper for JSON endpoints used by get_version_history to call the eCFR API and return parsed JSON data.
async def _get_json( path: str, params: dict[str, Any] | None = None, timeout: float = DEFAULT_TIMEOUT_JSON, ) -> dict[str, Any]: """GET helper for JSON endpoints. Always returns a dict (empty if API returned null).""" try: r = await _get_client().get(path, params=params or {}, timeout=timeout) except httpx.RequestError as e: raise RuntimeError(f"Network error calling eCFR: {e}") from e if r.status_code >= 400: raise RuntimeError(_format_error(r.status_code, r.text)) try: data = r.json() except (ValueError, _json.JSONDecodeError) as e: preview = _clean_error_body(r.text or "(empty body)")[:200] ct = r.headers.get("content-type", "?") raise RuntimeError( f"eCFR returned a non-JSON response (status {r.status_code}, " f"content-type={ct!r}): {preview}" ) from e if data is None: return {} if not isinstance(data, (dict, list)): raise RuntimeError( f"eCFR returned unexpected JSON type {type(data).__name__}: {str(data)[:200]}" ) return data if isinstance(data, dict) else {"_list": data}