File size: 26,121 Bytes
d984ed3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
"""
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,
        )