""" 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__) # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- 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" # These defaults are not currently fetched from a remote source. They reflect # the toolchain versions that work across the MC 1.21 / 26.x range. Override # them on the returned FabricConfig if your project requires different values. DEFAULT_LOOM_VERSION = "1.7-SNAPSHOT" DEFAULT_GRADLE_VERSION = "8.8" # Lowest Minecraft version the oracle will consider for "latest" resolution. MINIMUM_MC_VERSION_ID = "1.21" # Cache TTLs MANIFEST_CACHE_TTL_SECONDS = 600 # 10 minutes — manifest doesn't change often # Safety caps so a runaway loop never hammers the upstream APIs. MAX_SNAPSHOTS_TO_PROBE = 20 MAX_RELEASES_TO_PROBE = 10 # --------------------------------------------------------------------------- # Version string parsing # --------------------------------------------------------------------------- # Match the space form "26.3 Snapshot 2" OR the dash form "26.3-snapshot-2" # (the dash form is what Mojang's manifest actually publishes). _SNAPSHOT_RE = re.compile( r"^\s*(\d+)\.(\d+)(?:\.(\d+))?\s*[-\s]+[Ss]napshot\s*[-\s]*(\d+)\s*$" ) # Pre-release and release candidate forms: "26.2-pre-6", "26.2-rc-1". # Treated as snapshots for sorting (below the corresponding release). _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*$") # Legacy weekly-snapshot format like "23w13a", "25w46a". Used by Mojang for # older snapshots. We need to recognize these so we can correctly *exclude* # pre-1.21 ones from the "latest" candidate pool. _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)) # type marker: 1 = snapshot (lower than release's 2) 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)) # Treat pre-releases as snapshots for ordering purposes. 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 # type marker: 2 = release return (major, minor, patch, 2) # Legacy weekly snapshots like "23w13a", "25w46a". Map them onto the # (year, week) tuple. Year 23 == 2023, week 13. We use the same 4-tuple # shape with a sentinel marker so they sort *above* 1.21 if year>=24 but # below 1.21 otherwise. (24wXXa corresponds to MC 1.21 snapshots.) weekly = _WEEKLY_SNAPSHOT_RE.match(version_id) if weekly: year_short = int(weekly.group(1)) week = int(weekly.group(2)) # Weekly snapshots sort *below* the corresponding release: use type # marker 1. Major version = full year (e.g. 23 -> 2023) so 23w13a # (2023) sorts below 1.21 (which is (1, 21, 0, 2)). return (2000 + year_short, week, 0, 1) # Truly unparseable: return (0,) so it sorts below everything. 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,)). """ # Special case: legacy weekly snapshots (YYwNNa format). 1.21 was # released on 2024-06-13 (ISO week 24). Snapshots at or after that # point are experimental snapshots for 1.21.x+ content. 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 # Minecraft 26.1 (released March 2026) is the first release where Mojang # shipped the Java jar *unobfuscated*, with parameter names and (limited) # Javadoc-style comments included. As a consequence, the Fabric Project # officially retired Yarn mappings — Yarn builds simply don't exist for # 26.1+. Mods targeting 26.1+ use Mojang's official mappings directly via # Loom's `loom.officialMojangMappings()` call in build.gradle, and the # `gradle.properties` file does NOT carry a `yarn_mappings` line. # # Source: https://docs.fabricmc.net/develop/porting/mappings # "Minecraft 26.1 is unobfuscated and includes parameter names, # so there is no need for any obfuscation mappings." 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. """ # Weekly snapshots from 2025+ (25wXXa) are 1.21.x dev cycle, still # obfuscated. Only year-based 26.1+ versions are unobfuscated. 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 # --------------------------------------------------------------------------- # Resolver # --------------------------------------------------------------------------- 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: # A shared client gives us connection pooling and HTTP/2 multiplexing. self._client: httpx.AsyncClient = http_client or httpx.AsyncClient( timeout=httpx.Timeout(30.0), headers={"User-Agent": "fabric-config-oracle/1.0"}, ) # In-process caches. They live for the lifetime of the resolver # (i.e. for the lifetime of the MCP server process). The manifest # cache additionally has a TTL; the per-version caches do not, but # they're small (one entry per probed MC version) and never grow # unboundedly because we cap probes via MAX_*_TO_PROBE. 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]]] = {} # -- lifecycle -------------------------------------------------------- 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() # -- low-level HTTP --------------------------------------------------- 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: # noqa: BLE001 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 # -- API wrappers ----------------------------------------------------- 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", "") # Only release/snapshot types are relevant to us. Mojang also # exposes 'old_beta' / 'old_alpha' which we deliberately skip. 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, # type: ignore[arg-type] 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 # Fabric Meta returns Yarn builds newest-first, but sort defensively. 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 # First entry is the latest stable loader per Fabric Meta convention. 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] # Modrinth expects JSON-encoded array query params, e.g. # ?game_versions=["1.21.4"]&loaders=["fabric"] 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): # Only Fabric API required; Yarn doesn't exist for 26.1+. fabric_api = await self.fetch_fabric_api(mc_version) return fabric_api is not None # Obfuscated path: Yarn + Fabric API both required. 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 # -- resolution ------------------------------------------------------- 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 # Direct (specific-version) request — no fallback logic. if target_version and target_version.lower() != "latest": for v in versions: if v.id == target_version: return v # Case-insensitive fallback (some users mistype casing). for v in versions: if v.id.lower() == target_version.lower(): return v # Not in manifest. Return None so the caller can still try to # fetch dependencies directly — Fabric Meta / Modrinth might # know about it even if Mojang's manifest hasn't updated yet. return None # "latest" — apply snapshot-first, release-fallback resolution. versions_sorted = sorted(versions, key=lambda v: v.release_time, reverse=True) # Step 3: probe newest snapshots. 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 # Step 4: fall back to newest stable releases. 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 # Step 5: last resort — return the newest release even without # confirmed support, so we can surface a meaningful error message # via FabricConfig.message. 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." ), ) # For unobfuscated versions (26.1+), Yarn does not exist and is not # needed — mods use Mojang's official Mojmap directly via Loom. Skip # the Yarn fetch entirely to avoid a wasted 404 round-trip. 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", "") # Modrinth's `version_number` is the human-facing string # (e.g. "0.100.0+1.21.4"). `id` is the URL-safe UUID. fabric_api_version = (fabric_api or {}).get("version_number", "") # Build the missing-deps list. For unobfuscated versions, Yarn is # intentionally absent — it's not "missing", it's deprecated. 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, )