| """ |
| Version resolver: turns "latest" (or a specific version string) into a |
| fully-resolved Fabric build configuration by consulting the Mojang manifest, |
| Fabric Meta, and Modrinth APIs. |
| |
| The most subtle part of this module is the **snapshot fallback logic** in |
| `VersionResolver.resolve_version`. See its docstring for the rationale. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import asyncio |
| import logging |
| import re |
| from datetime import datetime |
| from typing import Any, Optional |
|
|
| import httpx |
|
|
| from models import FabricConfig, MinecraftVersion |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| |
| |
| |
|
|
| MOJANG_MANIFEST_URL = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json" |
| FABRIC_YARN_URL = "https://meta.fabricmc.net/v2/versions/yarn/{mc_version}" |
| FABRIC_LOADER_URL = "https://meta.fabricmc.net/v2/versions/loader" |
| MODRINTH_FABRIC_API_URL = "https://api.modrinth.com/v2/project/fabric-api/version" |
|
|
| |
| |
| |
| DEFAULT_LOOM_VERSION = "1.7-SNAPSHOT" |
| DEFAULT_GRADLE_VERSION = "8.8" |
|
|
| |
| MINIMUM_MC_VERSION_ID = "1.21" |
|
|
| |
| MANIFEST_CACHE_TTL_SECONDS = 600 |
|
|
| |
| MAX_SNAPSHOTS_TO_PROBE = 20 |
| MAX_RELEASES_TO_PROBE = 10 |
|
|
|
|
| |
| |
| |
|
|
|
|
| |
| |
| _SNAPSHOT_RE = re.compile( |
| r"^\s*(\d+)\.(\d+)(?:\.(\d+))?\s*[-\s]+[Ss]napshot\s*[-\s]*(\d+)\s*$" |
| ) |
| |
| |
| _PRE_RE = re.compile( |
| r"^\s*(\d+)\.(\d+)(?:\.(\d+))?\s*[-\s]+(?:pre|rc)\s*[-\s]*(\d+)\s*$" |
| ) |
| _RELEASE_RE = re.compile(r"^\s*(\d+)\.(\d+)(?:\.(\d+))?\s*$") |
|
|
| |
| |
| |
| _WEEKLY_SNAPSHOT_RE = re.compile(r"^\s*(\d{2})w(\d{1,2})[a-z]\s*$") |
|
|
|
|
| def parse_mc_version(version_id: str) -> tuple[int, ...]: |
| """ |
| Parse a Minecraft version string into a comparable tuple. |
| |
| Minecraft recently switched from `1.21.x` style versioning to year-based |
| `26.x` versioning. A standard semver parser would treat `1.21.4` as |
| *newer* than `26.2` because semver would parse `1` and `26` as the major |
| version. We instead want `26.2` to be newer, because year 2026 follows |
| year 2024 (when `1.21` shipped). |
| |
| The trick: keep the numeric components in tuple order. Python's tuple |
| comparison walks left-to-right, so `(26, 2, 0, ...)` > `(1, 21, 0, ...)` |
| because `26 > 1`. No special "year offset" is needed. |
| |
| Snapshots are sorted *below* their corresponding release by appending a |
| type marker (`1` for snapshot, `2` for release) followed by the snapshot |
| number for snapshots. This means: |
| `26.3 Snapshot 2` < `26.3 Snapshot 10` < `26.3` (release) |
| |
| Supports three input forms (all observed in Mojang's manifest): |
| - "26.3 Snapshot 2" (space form, user-facing) |
| - "26.3-snapshot-2" (dash form, manifest id) |
| - "26.2-pre-6" (dash form, pre-release) |
| - "26.2-rc-1" (dash form, release candidate) |
| |
| Returns a tuple of ints. Unparseable strings return (0,) so they always |
| sort below any real version β this is intentional, because the resolver's |
| `is_minimum_version` filter then excludes them from the "latest" pool. |
| """ |
| snap = _SNAPSHOT_RE.match(version_id) |
| if snap: |
| major = int(snap.group(1)) |
| minor = int(snap.group(2)) |
| patch = int(snap.group(3)) if snap.group(3) else 0 |
| snap_num = int(snap.group(4)) |
| |
| return (major, minor, patch, 1, snap_num) |
|
|
| pre = _PRE_RE.match(version_id) |
| if pre: |
| major = int(pre.group(1)) |
| minor = int(pre.group(2)) |
| patch = int(pre.group(3)) if pre.group(3) else 0 |
| pre_num = int(pre.group(4)) |
| |
| return (major, minor, patch, 1, pre_num) |
|
|
| rel = _RELEASE_RE.match(version_id) |
| if rel: |
| major = int(rel.group(1)) |
| minor = int(rel.group(2)) |
| patch = int(rel.group(3)) if rel.group(3) else 0 |
| |
| return (major, minor, patch, 2) |
|
|
| |
| |
| |
| |
| weekly = _WEEKLY_SNAPSHOT_RE.match(version_id) |
| if weekly: |
| year_short = int(weekly.group(1)) |
| week = int(weekly.group(2)) |
| |
| |
| |
| return (2000 + year_short, week, 0, 1) |
|
|
| |
| return (0,) |
|
|
|
|
| def is_minimum_version(version_id: str, minimum: str = MINIMUM_MC_VERSION_ID) -> bool: |
| """ |
| True iff `version_id` >= `minimum` under our custom parser. |
| |
| For minimum "1.21", this rejects: |
| - Legacy weekly snapshots from before 1.21's release (mid-2024). |
| Weekly snapshots live in a separate "epoch" (year + week) that |
| doesn't tuple-compare cleanly against `1.x` / `26.x` versions, so |
| we apply a separate cutoff: weekly snapshots at or after week 24 |
| of 2024 (when 1.21 dropped) are considered "above 1.21". |
| - Truly unparseable strings (which return (0,)). |
| """ |
| |
| |
| |
| weekly = _WEEKLY_SNAPSHOT_RE.match(version_id) |
| if weekly: |
| year_short = int(weekly.group(1)) |
| week = int(weekly.group(2)) |
| return (year_short, week) >= (24, 24) |
|
|
| try: |
| return parse_mc_version(version_id) >= parse_mc_version(minimum) |
| except Exception: |
| return False |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| UNOBFUSCATED_MINIMUM = "26.1" |
|
|
|
|
| def is_unobfuscated(version_id: str) -> bool: |
| """ |
| True iff `version_id` is an unobfuscated Minecraft release (>= 26.1). |
| |
| For these versions: |
| - Yarn mappings do NOT exist on Fabric Meta and never will. |
| - The Fabric ecosystem (Yarn, Loader, Fabric API) still works β only |
| the mappings layer is replaced by Mojang's official Mojmap. |
| - Loom reads Mojmap via `loom.officialMojangMappings()` in build.gradle |
| instead of `mappings "net.fabricmc:yarn:..."`. |
| |
| The resolver uses this to decide whether Yarn is a hard requirement |
| when probing for "latest" support. |
| """ |
| |
| |
| if _WEEKLY_SNAPSHOT_RE.match(version_id): |
| return False |
| try: |
| return parse_mc_version(version_id) >= parse_mc_version(UNOBFUSCATED_MINIMUM) |
| except Exception: |
| return False |
|
|
|
|
| |
| |
| |
|
|
|
|
| class VersionResolver: |
| """ |
| Resolves the optimal Fabric configuration for a Minecraft version. |
| |
| ## Snapshot fallback logic |
| |
| When `target_version` is `"latest"` (or omitted), the resolver must be |
| *intelligent* about which version it picks. Minecraft snapshots are |
| released frequently by Mojang, but the Fabric ecosystem (Fabric API mod |
| for all versions; Yarn mappings only for *obfuscated* versions) often |
| lags by days. If we blindly returned the newest snapshot, the AI might |
| generate a `gradle.properties` that references Fabric API versions that |
| *do not exist yet*, and the user's mod would fail to build. |
| |
| The resolver therefore: |
| |
| 1. Fetches the Mojang manifest and keeps only versions >= `1.21`. |
| 2. Sorts them by `releaseTime` descending (newest first). |
| 3. Iterates the newest snapshots, one by one. For each, it asks |
| `has_fabric_support(mc_version)`, which: |
| - For OBFUSCATED versions (1.21.x and earlier): requires BOTH |
| Yarn mappings AND a Fabric API build. |
| - For UNOBFUSCATED versions (26.1+, per Mojang's Oct 2025 |
| announcement and Fabric's deprecation of Yarn): requires ONLY |
| a Fabric API build. Yarn doesn't exist for these versions |
| and is not needed β mods use Mojang's official Mojmap via |
| Loom's `loom.officialMojangMappings()`. |
| If support is confirmed, that snapshot is returned immediately. |
| 4. If none of the probed snapshots have support, the resolver repeats |
| the same check against the newest stable releases. |
| 5. If even releases have no support (extremely unlikely β would |
| indicate a Fabric ecosystem outage), it returns the newest release |
| as a last-resort so the tool can still produce a useful error. |
| |
| Iteration is *sequential* not parallel. This is intentional: the typical |
| case is that the 1st or 2nd snapshot already has support, so we make at |
| most 2-3 API round-trips total. Parallel probing would always make N |
| round-trips and risk rate-limiting on Modrinth. |
| |
| See: https://docs.fabricmc.net/develop/porting/mappings |
| """ |
|
|
| def __init__(self, http_client: Optional[httpx.AsyncClient] = None) -> None: |
| |
| self._client: httpx.AsyncClient = http_client or httpx.AsyncClient( |
| timeout=httpx.Timeout(30.0), |
| headers={"User-Agent": "fabric-config-oracle/1.0"}, |
| ) |
|
|
| |
| |
| |
| |
| |
| self._manifest_cache: Optional[dict[str, Any]] = None |
| self._manifest_cache_time: Optional[datetime] = None |
| self._loader_cache: Optional[list[dict[str, Any]]] = None |
| self._yarn_cache: dict[str, Optional[dict[str, Any]]] = {} |
| self._fabric_api_cache: dict[str, Optional[dict[str, Any]]] = {} |
|
|
| |
|
|
| async def aclose(self) -> None: |
| """Close the underlying HTTP client. Safe to call multiple times.""" |
| await self._client.aclose() |
|
|
| async def __aenter__(self) -> "VersionResolver": |
| return self |
|
|
| async def __aexit__(self, *_: object) -> None: |
| await self.aclose() |
|
|
| |
|
|
| async def _fetch_json( |
| self, |
| url: str, |
| params: Optional[dict[str, Any]] = None, |
| ) -> Any: |
| """ |
| Fetch JSON from `url` with graceful error handling. |
| |
| Returns: |
| - The parsed JSON value on success. |
| - `None` on 404 (treated as "no data available"). |
| - `None` on any other error after logging. |
| |
| 429 (rate limited) responses trigger a single backoff-and-retry. |
| """ |
| try: |
| response = await self._client.get(url, params=params) |
| response.raise_for_status() |
| return response.json() |
| except httpx.HTTPStatusError as exc: |
| status = exc.response.status_code |
| if status == 404: |
| return None |
| if status == 429: |
| logger.warning("Rate limited by %s; backing off 2s and retrying once.", url) |
| await asyncio.sleep(2.0) |
| try: |
| retry = await self._client.get(url, params=params) |
| retry.raise_for_status() |
| return retry.json() |
| except Exception as retry_exc: |
| logger.error("Retry of %s failed: %s", url, retry_exc) |
| return None |
| logger.error("HTTP %d from %s: %s", status, url, exc) |
| return None |
| except (httpx.RequestError, ValueError) as exc: |
| logger.error("Error fetching %s: %s", url, exc) |
| return None |
|
|
| |
|
|
| async def fetch_mojang_manifest(self) -> list[MinecraftVersion]: |
| """Fetch and parse the Mojang manifest, filtered to versions >= 1.21.""" |
| now = datetime.now() |
| if ( |
| self._manifest_cache is not None |
| and self._manifest_cache_time is not None |
| and (now - self._manifest_cache_time).total_seconds() < MANIFEST_CACHE_TTL_SECONDS |
| ): |
| return self._parse_manifest(self._manifest_cache) |
|
|
| data = await self._fetch_json(MOJANG_MANIFEST_URL) |
| if data is None: |
| if self._manifest_cache is not None: |
| logger.warning("Mojang manifest fetch failed; using stale cache.") |
| return self._parse_manifest(self._manifest_cache) |
| logger.error("Mojang manifest unavailable and no cache to fall back on.") |
| return [] |
|
|
| self._manifest_cache = data |
| self._manifest_cache_time = now |
| return self._parse_manifest(data) |
|
|
| def _parse_manifest(self, data: dict[str, Any]) -> list[MinecraftVersion]: |
| """Parse raw manifest JSON into a list of MinecraftVersion objects.""" |
| out: list[MinecraftVersion] = [] |
| for v in data.get("versions", []): |
| vid = v.get("id", "") |
| vtype = v.get("type", "") |
| |
| |
| if vtype not in ("release", "snapshot"): |
| continue |
| if not is_minimum_version(vid): |
| continue |
| try: |
| rt_raw = v["releaseTime"] |
| rt = datetime.fromisoformat(rt_raw.replace("Z", "+00:00")) |
| except (KeyError, ValueError): |
| continue |
| out.append( |
| MinecraftVersion( |
| id=vid, |
| type=vtype, |
| release_time=rt, |
| url=v.get("url"), |
| ) |
| ) |
| return out |
|
|
| async def fetch_yarn(self, mc_version: str) -> Optional[dict[str, Any]]: |
| """Fetch the newest Yarn mappings build for `mc_version`, or None.""" |
| if mc_version in self._yarn_cache: |
| return self._yarn_cache[mc_version] |
|
|
| data = await self._fetch_json(FABRIC_YARN_URL.format(mc_version=mc_version)) |
| if not isinstance(data, list) or len(data) == 0: |
| self._yarn_cache[mc_version] = None |
| return None |
|
|
| |
| data.sort(key=lambda y: y.get("build", 0), reverse=True) |
| yarn = data[0] |
| self._yarn_cache[mc_version] = yarn |
| return yarn |
|
|
| async def fetch_loader(self) -> Optional[dict[str, Any]]: |
| """Fetch the newest stable Fabric Loader version.""" |
| if self._loader_cache is not None: |
| return self._loader_cache[0] if self._loader_cache else None |
|
|
| data = await self._fetch_json(FABRIC_LOADER_URL) |
| if not isinstance(data, list) or len(data) == 0: |
| return None |
|
|
| self._loader_cache = data |
| |
| return data[0] |
|
|
| async def fetch_fabric_api(self, mc_version: str) -> Optional[dict[str, Any]]: |
| """Fetch the newest Fabric API mod build for `mc_version` from Modrinth.""" |
| if mc_version in self._fabric_api_cache: |
| return self._fabric_api_cache[mc_version] |
|
|
| |
| |
| params = { |
| "game_versions": f'["{mc_version}"]', |
| "loaders": '["fabric"]', |
| } |
| data = await self._fetch_json(MODRINTH_FABRIC_API_URL, params=params) |
| if not isinstance(data, list) or len(data) == 0: |
| self._fabric_api_cache[mc_version] = None |
| return None |
|
|
| data.sort(key=lambda v: v.get("date_published", ""), reverse=True) |
| fabric_api = data[0] |
| self._fabric_api_cache[mc_version] = fabric_api |
| return fabric_api |
|
|
| async def has_fabric_support(self, mc_version: str) -> bool: |
| """ |
| True iff `mc_version` has the Fabric dependencies required to build |
| a working mod. |
| |
| ## Unobfuscated versions (26.1+) |
| |
| As of Minecraft 26.1 (March 2026), Mojang ships Java jars |
| unobfuscated, so Yarn mappings are deprecated and no longer |
| published. Mods targeting 26.1+ use Mojang's official Mojmap |
| directly via Loom's `loom.officialMojangMappings()`. For these |
| versions, the only Fabric dependency we need to verify is Fabric |
| API on Modrinth. |
| |
| ## Obfuscated versions (<= 1.21.11) |
| |
| For these, BOTH Yarn AND Fabric API must exist, since a mod |
| referencing obfuscated class names cannot compile without Yarn |
| (or another mapping set) to translate them. |
| |
| See: https://docs.fabricmc.net/develop/porting/mappings |
| """ |
| if is_unobfuscated(mc_version): |
| |
| fabric_api = await self.fetch_fabric_api(mc_version) |
| return fabric_api is not None |
|
|
| |
| yarn, fabric_api = await asyncio.gather( |
| self.fetch_yarn(mc_version), |
| self.fetch_fabric_api(mc_version), |
| ) |
| return yarn is not None and fabric_api is not None |
|
|
| |
|
|
| async def resolve_version( |
| self, |
| target_version: str = "latest", |
| ) -> Optional[MinecraftVersion]: |
| """ |
| Resolve a target Minecraft version, applying fallback logic for "latest". |
| |
| See the `VersionResolver` class docstring for the full snapshot |
| fallback rationale. |
| |
| Args: |
| target_version: Either "latest" (default) or a specific version |
| string like "1.21.4", "26.2", or "26.3 Snapshot 2". |
| |
| Returns: |
| A `MinecraftVersion` if resolution succeeded, else `None`. |
| |
| For specific (non-"latest") requests, the version is looked up in the |
| manifest without a support check. If it isn't in the manifest, `None` |
| is returned and the caller may still attempt to fetch dependencies for |
| it β the per-API wrappers will return empty results gracefully. |
| """ |
| versions = await self.fetch_mojang_manifest() |
| if not versions: |
| return None |
|
|
| |
| if target_version and target_version.lower() != "latest": |
| for v in versions: |
| if v.id == target_version: |
| return v |
| |
| for v in versions: |
| if v.id.lower() == target_version.lower(): |
| return v |
| |
| |
| |
| return None |
|
|
| |
| versions_sorted = sorted(versions, key=lambda v: v.release_time, reverse=True) |
|
|
| |
| snapshots = [v for v in versions_sorted if v.type == "snapshot"] |
| for snap in snapshots[:MAX_SNAPSHOTS_TO_PROBE]: |
| if await self.has_fabric_support(snap.id): |
| return snap |
|
|
| |
| releases = [v for v in versions_sorted if v.type == "release"] |
| for rel in releases[:MAX_RELEASES_TO_PROBE]: |
| if await self.has_fabric_support(rel.id): |
| return rel |
|
|
| |
| |
| |
| return releases[0] if releases else (snapshots[0] if snapshots else None) |
|
|
| async def resolve_config(self, target_version: str = "latest") -> FabricConfig: |
| """ |
| Resolve the full Fabric configuration for a target MC version. |
| |
| This is the high-level entry point used by the MCP tool. It first |
| resolves the version (applying fallback logic if "latest"), then |
| fetches Yarn, Loader, and Fabric API versions concurrently. |
| |
| The returned `FabricConfig.message` field always describes the |
| outcome β success, partial success, or failure β in human-readable |
| form so the AI caller can react accordingly. |
| """ |
| mc_version = await self.resolve_version(target_version) |
| if mc_version is None: |
| return FabricConfig( |
| minecraft_version=target_version, |
| version_type="release", |
| yarn_mappings="", |
| loader_version="", |
| fabric_api_version="", |
| loom_version=DEFAULT_LOOM_VERSION, |
| gradle_wrapper_version=DEFAULT_GRADLE_VERSION, |
| message=( |
| f"Could not resolve Minecraft version '{target_version}'. " |
| "It may not exist in the Mojang manifest, or the manifest " |
| "could not be fetched." |
| ), |
| ) |
|
|
| |
| |
| |
| unobf = is_unobfuscated(mc_version.id) |
|
|
| if unobf: |
| loader, fabric_api = await asyncio.gather( |
| self.fetch_loader(), |
| self.fetch_fabric_api(mc_version.id), |
| ) |
| yarn = None |
| else: |
| yarn, loader, fabric_api = await asyncio.gather( |
| self.fetch_yarn(mc_version.id), |
| self.fetch_loader(), |
| self.fetch_fabric_api(mc_version.id), |
| ) |
|
|
| yarn_version = (yarn or {}).get("version", "") |
| loader_version = (loader or {}).get("version", "") |
| |
| |
| fabric_api_version = (fabric_api or {}).get("version_number", "") |
|
|
| |
| |
| missing: list[str] = [] |
| if not unobf and not yarn_version: |
| missing.append("Yarn mappings") |
| if not loader_version: |
| missing.append("Fabric Loader") |
| if not fabric_api_version: |
| missing.append("Fabric API") |
|
|
| if missing: |
| message = ( |
| f"Configuration partially resolved for MC {mc_version.id}. " |
| f"Missing: {', '.join(missing)}." |
| ) |
| elif unobf: |
| message = ( |
| f"Configuration resolved successfully for MC {mc_version.id}. " |
| "Yarn mappings are not required (Minecraft 26.1+ ships " |
| "unobfuscated; mods use Mojang official Mojmap via Loom's " |
| "`loom.officialMojangMappings()` in build.gradle)." |
| ) |
| else: |
| message = "Configuration resolved successfully." |
|
|
| return FabricConfig( |
| minecraft_version=mc_version.id, |
| version_type=mc_version.type, |
| yarn_mappings=yarn_version, |
| loader_version=loader_version, |
| fabric_api_version=fabric_api_version, |
| loom_version=DEFAULT_LOOM_VERSION, |
| gradle_wrapper_version=DEFAULT_GRADLE_VERSION, |
| message=message, |
| ) |
|
|