Upload 10 files
Browse files- .dockerignore +21 -0
- Dockerfile +42 -5
- README.md +180 -5
- app.py +119 -0
- main.py +35 -0
- mcp_server.py +300 -0
- models.py +114 -0
- requirements.txt +22 -9
- resolver.py +613 -0
- smoke_test_http.py +313 -0
.dockerignore
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Don't ship dev artifacts into the HF Space image.
|
| 2 |
+
.git
|
| 3 |
+
.gitignore
|
| 4 |
+
.venv
|
| 5 |
+
venv
|
| 6 |
+
__pycache__
|
| 7 |
+
*.pyc
|
| 8 |
+
*.pyo
|
| 9 |
+
*.pyd
|
| 10 |
+
.pytest_cache
|
| 11 |
+
.mypy_cache
|
| 12 |
+
.ruff_cache
|
| 13 |
+
.DS_Store
|
| 14 |
+
*.egg-info
|
| 15 |
+
build/
|
| 16 |
+
dist/
|
| 17 |
+
.env
|
| 18 |
+
.env.local
|
| 19 |
+
*.log
|
| 20 |
+
tests/
|
| 21 |
+
scripts/
|
Dockerfile
CHANGED
|
@@ -1,12 +1,49 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
|
| 8 |
-
|
|
|
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
EXPOSE 7860
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Spaces Dockerfile for fabric-config-oracle MCP server.
|
| 2 |
+
#
|
| 3 |
+
# HF Spaces convention:
|
| 4 |
+
# - Listen on port 7860 (HF proxies public traffic here).
|
| 5 |
+
# - Run as non-root user `user` (uid 1000) — HF enforces this.
|
| 6 |
+
# - Use a slim Python base image to keep the layer small.
|
| 7 |
+
#
|
| 8 |
+
# After the Space is up, the public MCP endpoint is:
|
| 9 |
+
# https://<your-space-name>.hf.space/mcp
|
| 10 |
|
| 11 |
+
FROM python:3.12-slim
|
| 12 |
|
| 13 |
+
# Avoid Python writing .pyc files and forcing stdout/stderr to be unbuffered.
|
| 14 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 15 |
+
PYTHONUNBUFFERED=1 \
|
| 16 |
+
PIP_NO_CACHE_DIR=1 \
|
| 17 |
+
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
| 18 |
+
FASTMCP_HOST=0.0.0.0 \
|
| 19 |
+
FASTMCP_PORT=7860
|
| 20 |
+
|
| 21 |
+
# Create the non-root user HF Spaces requires.
|
| 22 |
+
RUN useradd --create-home --uid 1000 user
|
| 23 |
+
|
| 24 |
+
WORKDIR /home/user/app
|
| 25 |
+
|
| 26 |
+
# Install Python deps first (better Docker layer caching).
|
| 27 |
+
# Copy just the requirements file, install as root, then drop privileges.
|
| 28 |
+
COPY --chown=user:user requirements.txt ./
|
| 29 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 30 |
|
| 31 |
+
# Copy the rest of the source.
|
| 32 |
+
COPY --chown=user:user . .
|
| 33 |
|
| 34 |
+
# Switch to the non-root user.
|
| 35 |
+
USER user
|
| 36 |
+
|
| 37 |
+
# HF Spaces expects the container to listen on 7860.
|
| 38 |
EXPOSE 7860
|
| 39 |
|
| 40 |
+
# Healthcheck — a 200 from the MCP endpoint means the server is up.
|
| 41 |
+
# Streamable HTTP returns 406 to non-MCP requests, so we just check
|
| 42 |
+
# that *something* responds (any HTTP status is OK; timeout means dead).
|
| 43 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
| 44 |
+
CMD python -c "import urllib.request, sys; \
|
| 45 |
+
try: urllib.request.urlopen('http://127.0.0.1:7860/mcp', timeout=3); \
|
| 46 |
+
except Exception: sys.exit(0); sys.exit(0)"
|
| 47 |
+
|
| 48 |
+
# Run the HTTP entry point.
|
| 49 |
+
CMD ["python", "app.py"]
|
README.md
CHANGED
|
@@ -1,9 +1,184 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Fabric Config Oracle MCP
|
| 3 |
+
emoji: 🧵
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: docker
|
| 7 |
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
short_description: MCP server for Minecraft Fabric mod build configs
|
| 11 |
+
tags:
|
| 12 |
+
- mcp
|
| 13 |
+
- minecraft
|
| 14 |
+
- fabric
|
| 15 |
+
- model-context-protocol
|
| 16 |
+
- fabricmc
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
# Fabric Config Oracle — MCP Server (Hugging Face Space)
|
| 20 |
+
|
| 21 |
+
An MCP (Model Context Protocol) server that acts as a **Configuration Oracle**
|
| 22 |
+
for AI assistants building or updating Minecraft Fabric mods. When an AI
|
| 23 |
+
(like Claude or Cursor) is asked to scaffold a Fabric mod, it can query this
|
| 24 |
+
server to instantly get the exact, correct dependency versions, mappings, and
|
| 25 |
+
build configurations for any Minecraft version from `1.21` up to the latest
|
| 26 |
+
`26.x` snapshot.
|
| 27 |
+
|
| 28 |
+
This Space exposes the MCP server over the **streamable HTTP transport** so
|
| 29 |
+
any MCP-compatible client on the internet can connect to it without installing
|
| 30 |
+
anything locally.
|
| 31 |
+
|
| 32 |
+
## Endpoint
|
| 33 |
+
|
| 34 |
+
```
|
| 35 |
+
https://<this-space-name>.hf.space/mcp
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
## How to connect from a client
|
| 39 |
+
|
| 40 |
+
### Claude Desktop (`claude_desktop_config.json`)
|
| 41 |
+
|
| 42 |
+
```json
|
| 43 |
+
{
|
| 44 |
+
"mcpServers": {
|
| 45 |
+
"fabric-config-oracle": {
|
| 46 |
+
"url": "https://<this-space-name>.hf.space/mcp",
|
| 47 |
+
"type": "http"
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
### Cursor (`.cursor/mcp.json`)
|
| 54 |
+
|
| 55 |
+
```json
|
| 56 |
+
{
|
| 57 |
+
"mcpServers": {
|
| 58 |
+
"fabric-config-oracle": {
|
| 59 |
+
"url": "https://<this-space-name>.hf.space/mcp"
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
### Generic streamable HTTP client
|
| 66 |
+
|
| 67 |
+
```bash
|
| 68 |
+
curl -X POST https://<this-space-name>.hf.space/mcp \
|
| 69 |
+
-H "Content-Type: application/json" \
|
| 70 |
+
-H "Accept: application/json, text/event-stream" \
|
| 71 |
+
-d '{
|
| 72 |
+
"jsonrpc": "2.0",
|
| 73 |
+
"id": 1,
|
| 74 |
+
"method": "initialize",
|
| 75 |
+
"params": {
|
| 76 |
+
"protocolVersion": "2025-06-18",
|
| 77 |
+
"capabilities": {},
|
| 78 |
+
"clientInfo": {"name": "demo", "version": "0.0.1"}
|
| 79 |
+
}
|
| 80 |
+
}'
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
## Why this exists
|
| 84 |
+
|
| 85 |
+
Minecraft snapshots are released frequently by Mojang, but the Fabric
|
| 86 |
+
ecosystem (Yarn mappings, Fabric API) often takes a few days to catch up. If
|
| 87 |
+
an AI blindly writes a `gradle.properties` targeting the newest snapshot, the
|
| 88 |
+
mod will fail to build because Yarn or Fabric API don't support that snapshot
|
| 89 |
+
yet. This server is **intelligent**: when asked for `"latest"`, it walks the
|
| 90 |
+
newest snapshots and only returns one that has full Fabric support, falling
|
| 91 |
+
back to the newest stable release if no snapshots are ready.
|
| 92 |
+
|
| 93 |
+
## Important: Mojang unobfuscation (MC 26.1+)
|
| 94 |
+
|
| 95 |
+
Starting with Minecraft 26.1, Mojang ships the game jar **unobfuscated** with
|
| 96 |
+
parameter names intact. This deprecates Yarn mappings for those versions —
|
| 97 |
+
mods targeting MC 26.1+ should use `loom.officialMojangMappings()` in
|
| 98 |
+
`build.gradle` instead of the Yarn `mappings "..."` declaration.
|
| 99 |
+
|
| 100 |
+
This server is aware of that distinction:
|
| 101 |
+
- For obfuscated versions (`1.21.x` and earlier): requires Yarn + Fabric API.
|
| 102 |
+
- For unobfuscated versions (`26.1`+): requires only Fabric API; the
|
| 103 |
+
`yarn_mappings` field is returned empty and the `generate_gradle_properties`
|
| 104 |
+
tool omits that line entirely (with a comment explaining why).
|
| 105 |
+
|
| 106 |
+
Source: <https://docs.fabricmc.net/develop/porting/mappings>
|
| 107 |
+
|
| 108 |
+
## Tools
|
| 109 |
+
|
| 110 |
+
### `get_optimal_fabric_config`
|
| 111 |
+
|
| 112 |
+
Resolve the optimal Fabric build config for a Minecraft version.
|
| 113 |
+
|
| 114 |
+
**Args:**
|
| 115 |
+
- `minecraft_version` (str, default `"latest"`): target MC version or `"latest"`.
|
| 116 |
+
- `mod_id` (str, optional): echoed in the response payload.
|
| 117 |
+
|
| 118 |
+
**Returns:** JSON object with `minecraft_version`, `version_type`,
|
| 119 |
+
`yarn_mappings`, `loader_version`, `fabric_api_version`, `loom_version`,
|
| 120 |
+
`gradle_wrapper_version`, `message`, and (if provided) `mod_id`.
|
| 121 |
+
|
| 122 |
+
### `generate_gradle_properties`
|
| 123 |
+
|
| 124 |
+
Render a complete `gradle.properties` file from a resolved config.
|
| 125 |
+
|
| 126 |
+
**Args:**
|
| 127 |
+
- `mod_name` (str): human-readable mod name.
|
| 128 |
+
- `mod_version` (str): mod version string.
|
| 129 |
+
- `minecraft_version` (str, default `"latest"`): target MC version.
|
| 130 |
+
|
| 131 |
+
**Returns:** plain-text content of `gradle.properties`, ready to write to disk.
|
| 132 |
+
|
| 133 |
+
## Resources
|
| 134 |
+
|
| 135 |
+
- `fabric://config/schema/gradle_properties` — static reference schema for
|
| 136 |
+
a Fabric mod's `gradle.properties` file. Documents both the Yarn (≤1.21.x)
|
| 137 |
+
and Mojmap (≥26.1) regimes.
|
| 138 |
+
|
| 139 |
+
## Caching & rate limits
|
| 140 |
+
|
| 141 |
+
The server caches:
|
| 142 |
+
- The Mojang version manifest for 10 minutes (`MANIFEST_CACHE_TTL_SECONDS=600`).
|
| 143 |
+
- Per-version Yarn mappings and Fabric API versions for the process lifetime.
|
| 144 |
+
|
| 145 |
+
Upstream APIs queried:
|
| 146 |
+
- `https://piston-meta.mojang.com/mc/game/version_manifest_v2.json` (Mojang)
|
| 147 |
+
- `https://meta.fabricmc.net/v2/` (Fabric Meta — Yarn, Loader)
|
| 148 |
+
- `https://api.modrinth.com/v2/` (Fabric API versions)
|
| 149 |
+
|
| 150 |
+
On Modrinth `429 Too Many Requests`, the server backs off for 2 seconds and
|
| 151 |
+
retries once.
|
| 152 |
+
|
| 153 |
+
## Privacy & security
|
| 154 |
+
|
| 155 |
+
This is a **fully open, public** MCP server. There is no authentication.
|
| 156 |
+
Anyone with the Space URL can call the tools.
|
| 157 |
+
|
| 158 |
+
The server only makes outbound requests to the three official upstream APIs
|
| 159 |
+
listed above. It does not log, store, or forward caller-supplied data beyond
|
| 160 |
+
the tool arguments needed to compute the response. The `mod_id` argument is
|
| 161 |
+
echoed in the response payload and otherwise unused.
|
| 162 |
+
|
| 163 |
+
## Cold starts
|
| 164 |
+
|
| 165 |
+
HF Spaces on the free tier sleep after ~48 hours of inactivity. The first
|
| 166 |
+
request after sleep takes ~30 seconds to wake the container; subsequent
|
| 167 |
+
requests are fast. If you need always-on availability, upgrade the Space to a
|
| 168 |
+
paid hardware tier.
|
| 169 |
+
|
| 170 |
+
## Local development
|
| 171 |
+
|
| 172 |
+
```bash
|
| 173 |
+
# stdio mode (for local MCP clients like Claude Desktop)
|
| 174 |
+
python main.py
|
| 175 |
+
|
| 176 |
+
# HTTP mode (mirrors what this Space runs)
|
| 177 |
+
pip install -r requirements.txt
|
| 178 |
+
python app.py
|
| 179 |
+
# → http://localhost:7860/mcp
|
| 180 |
+
```
|
| 181 |
+
|
| 182 |
+
## License
|
| 183 |
+
|
| 184 |
+
MIT
|
app.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
HTTP entry point for the fabric-config-oracle MCP server.
|
| 3 |
+
|
| 4 |
+
This module exposes the same FastMCP server as `main.py`, but uses the
|
| 5 |
+
**streamable HTTP** transport instead of stdio. It is designed to run
|
| 6 |
+
behind a uvicorn ASGI server so the MCP can be hosted on platforms like
|
| 7 |
+
Hugging Face Spaces, Fly.io, Railway, or any container host that exposes
|
| 8 |
+
a public HTTP port.
|
| 9 |
+
|
| 10 |
+
Run locally:
|
| 11 |
+
uvicorn app:asgi_app --host 0.0.0.0 --port 7860
|
| 12 |
+
|
| 13 |
+
Or directly:
|
| 14 |
+
python app.py
|
| 15 |
+
|
| 16 |
+
Hugging Face Spaces:
|
| 17 |
+
The Dockerfile in this repo starts `python app.py`. The Space's
|
| 18 |
+
public URL (e.g. `https://<space-name>.hf.space/mcp`) is the MCP
|
| 19 |
+
endpoint clients configure in their `mcp.json`.
|
| 20 |
+
|
| 21 |
+
Security note:
|
| 22 |
+
The server is **fully open / public** by design. There is no bearer
|
| 23 |
+
token, no OAuth, no DNS-rebinding protection. Anyone with the URL
|
| 24 |
+
can call the tools. This is acceptable for a read-only oracle that
|
| 25 |
+
only queries public upstream APIs (Mojang / Fabric Meta / Modrinth).
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
from __future__ import annotations
|
| 29 |
+
|
| 30 |
+
import logging
|
| 31 |
+
import os
|
| 32 |
+
import sys
|
| 33 |
+
|
| 34 |
+
# Make sibling modules importable when running as `python app.py`.
|
| 35 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 36 |
+
|
| 37 |
+
from mcp.server.fastmcp import FastMCP # noqa: E402
|
| 38 |
+
from mcp.server.transport_security import TransportSecuritySettings # noqa: E402
|
| 39 |
+
|
| 40 |
+
from mcp_server import mcp as _configured_mcp # noqa: E402
|
| 41 |
+
from mcp_server import get_server # noqa: E402
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
logger = logging.getLogger("fabric-config-oracle.http")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def build_http_app() -> "FastMCP":
|
| 48 |
+
"""
|
| 49 |
+
Return a FastMCP instance configured for public HTTP hosting.
|
| 50 |
+
|
| 51 |
+
We rebuild the FastMCP server (rather than mutating the singleton
|
| 52 |
+
from `mcp_server.py`) so the stdio entry point in `main.py` keeps
|
| 53 |
+
its conservative localhost-only defaults. The HTTP variant:
|
| 54 |
+
|
| 55 |
+
* binds to 0.0.0.0 (required by HF Spaces, Fly.io, Docker)
|
| 56 |
+
* listens on port 7860 (HF Spaces convention)
|
| 57 |
+
* exposes the MCP endpoint at `/mcp` (FastMCP default)
|
| 58 |
+
* **disables DNS rebinding protection** — public hosts have
|
| 59 |
+
arbitrary Host headers by design.
|
| 60 |
+
* enables stateless HTTP mode so any client can POST a single
|
| 61 |
+
JSON-RPC request without first opening a long-lived session.
|
| 62 |
+
This is the friendliest mode for public MCP endpoints and
|
| 63 |
+
matches the official `streamable-http` examples.
|
| 64 |
+
* enables `json_response=True` so responses are returned as
|
| 65 |
+
Content-Type: application/json rather than text/event-stream.
|
| 66 |
+
Both are spec-compliant; JSON is simpler for clients that
|
| 67 |
+
don't want to parse SSE.
|
| 68 |
+
"""
|
| 69 |
+
server = get_server()
|
| 70 |
+
# Re-apply settings that matter for public hosting. We mutate the
|
| 71 |
+
# existing Settings object so tools/resources registered on the
|
| 72 |
+
# module-level `mcp` singleton keep working.
|
| 73 |
+
server.settings.host = os.environ.get("FASTMCP_HOST", "0.0.0.0")
|
| 74 |
+
server.settings.port = int(os.environ.get("FASTMCP_PORT", "7860"))
|
| 75 |
+
server.settings.streamable_http_path = "/mcp"
|
| 76 |
+
server.settings.stateless_http = True
|
| 77 |
+
server.settings.json_response = True
|
| 78 |
+
server.settings.transport_security = TransportSecuritySettings(
|
| 79 |
+
enable_dns_rebinding_protection=False,
|
| 80 |
+
allowed_hosts=[],
|
| 81 |
+
allowed_origins=[],
|
| 82 |
+
)
|
| 83 |
+
return server
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# Module-level ASGI app for `uvicorn app:asgi_app` invocation.
|
| 87 |
+
_http_server = build_http_app()
|
| 88 |
+
asgi_app = _http_server.streamable_http_app()
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def main() -> None:
|
| 92 |
+
"""Start the fabric-config-oracle MCP server over streamable HTTP."""
|
| 93 |
+
logging.basicConfig(
|
| 94 |
+
level=logging.INFO,
|
| 95 |
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
| 96 |
+
stream=sys.stderr,
|
| 97 |
+
)
|
| 98 |
+
host = _http_server.settings.host
|
| 99 |
+
port = _http_server.settings.port
|
| 100 |
+
logger.info(
|
| 101 |
+
"Starting fabric-config-oracle MCP server (streamable HTTP) "
|
| 102 |
+
"on http://%s:%s/mcp",
|
| 103 |
+
host,
|
| 104 |
+
port,
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
import uvicorn
|
| 108 |
+
|
| 109 |
+
uvicorn.run(
|
| 110 |
+
asgi_app,
|
| 111 |
+
host=host,
|
| 112 |
+
port=port,
|
| 113 |
+
log_level="info",
|
| 114 |
+
access_log=False, # MCP traffic is verbose; suppress per-request logs.
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
if __name__ == "__main__":
|
| 119 |
+
main()
|
main.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Entry point for the fabric-config-oracle MCP server.
|
| 3 |
+
|
| 4 |
+
Run via:
|
| 5 |
+
python main.py
|
| 6 |
+
|
| 7 |
+
The server speaks MCP over stdio, which is the transport Claude Desktop,
|
| 8 |
+
Cursor, and most other MCP-aware clients expect by default.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import logging
|
| 14 |
+
import sys
|
| 15 |
+
|
| 16 |
+
from mcp_server import get_server
|
| 17 |
+
|
| 18 |
+
logging.basicConfig(
|
| 19 |
+
level=logging.INFO,
|
| 20 |
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
| 21 |
+
stream=sys.stderr, # MCP uses stdout for protocol traffic; logs go to stderr.
|
| 22 |
+
)
|
| 23 |
+
logger = logging.getLogger("fabric-config-oracle")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def main() -> None:
|
| 27 |
+
"""Start the fabric-config-oracle MCP server over stdio transport."""
|
| 28 |
+
logger.info("Starting fabric-config-oracle MCP server (stdio transport)...")
|
| 29 |
+
server = get_server()
|
| 30 |
+
# `run()` blocks until the client disconnects or the process is killed.
|
| 31 |
+
server.run(transport="stdio")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
if __name__ == "__main__":
|
| 35 |
+
main()
|
mcp_server.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MCP server definition: registers tools and resources exposed by the
|
| 3 |
+
fabric-config-oracle over stdio transport.
|
| 4 |
+
|
| 5 |
+
Tools
|
| 6 |
+
-----
|
| 7 |
+
* `get_optimal_fabric_config` — resolve the optimal Fabric build config for a
|
| 8 |
+
specific or the latest Minecraft version, applying snapshot fallback logic.
|
| 9 |
+
* `generate_gradle_properties` — render a complete `gradle.properties` file
|
| 10 |
+
text from a resolved config plus mod metadata.
|
| 11 |
+
|
| 12 |
+
Resources
|
| 13 |
+
---------
|
| 14 |
+
* `fabric://config/schema/gradle_properties` — static reference schema for a
|
| 15 |
+
well-formed Fabric `gradle.properties` file.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import logging
|
| 21 |
+
from typing import Optional
|
| 22 |
+
|
| 23 |
+
from mcp.server.fastmcp import FastMCP
|
| 24 |
+
|
| 25 |
+
from models import FabricConfig
|
| 26 |
+
from resolver import VersionResolver
|
| 27 |
+
|
| 28 |
+
logger = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# ---------------------------------------------------------------------------
|
| 32 |
+
# Server + shared state
|
| 33 |
+
# ---------------------------------------------------------------------------
|
| 34 |
+
|
| 35 |
+
mcp: FastMCP = FastMCP("fabric-config-oracle")
|
| 36 |
+
|
| 37 |
+
# Module-level singleton resolver. The MCP stdio server runs in a single
|
| 38 |
+
# process for its entire lifetime, so a single shared HTTP client + cache
|
| 39 |
+
# is both safe and desirable (connection pooling, fewer upstream calls).
|
| 40 |
+
_resolver: Optional[VersionResolver] = None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _get_resolver() -> VersionResolver:
|
| 44 |
+
"""Lazily instantiate the shared VersionResolver."""
|
| 45 |
+
global _resolver
|
| 46 |
+
if _resolver is None:
|
| 47 |
+
_resolver = VersionResolver()
|
| 48 |
+
return _resolver
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ---------------------------------------------------------------------------
|
| 52 |
+
# Tool 1: get_optimal_fabric_config
|
| 53 |
+
# ---------------------------------------------------------------------------
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@mcp.tool()
|
| 57 |
+
async def get_optimal_fabric_config(
|
| 58 |
+
minecraft_version: str = "latest",
|
| 59 |
+
mod_id: Optional[str] = None,
|
| 60 |
+
) -> dict:
|
| 61 |
+
"""
|
| 62 |
+
Get the optimal Fabric mod configuration for a specific or the latest
|
| 63 |
+
Minecraft version.
|
| 64 |
+
|
| 65 |
+
When `minecraft_version` is "latest" (or omitted), the server applies
|
| 66 |
+
intelligent fallback logic: it walks the newest Minecraft snapshots and
|
| 67 |
+
returns the first one that has full Fabric ecosystem support.
|
| 68 |
+
|
| 69 |
+
**Support is defined differently depending on the MC version:**
|
| 70 |
+
- For OBFUSCATED versions (1.21.x and earlier): the snapshot must have
|
| 71 |
+
BOTH Yarn mappings AND a Fabric API build available.
|
| 72 |
+
- For UNOBFUSCATED versions (26.1+): only a Fabric API build is required.
|
| 73 |
+
Yarn mappings are deprecated and don't exist for these versions —
|
| 74 |
+
mods use Mojang's official Mojmap via Loom's
|
| 75 |
+
`loom.officialMojangMappings()` in build.gradle.
|
| 76 |
+
|
| 77 |
+
If no snapshots have full Fabric support yet, the server falls back to
|
| 78 |
+
the newest stable release that does.
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
minecraft_version: Target Minecraft version. Accepts:
|
| 82 |
+
- "latest" (default): apply fallback logic.
|
| 83 |
+
- Specific releases: "1.21", "1.21.4", "26.1", "26.2".
|
| 84 |
+
- Specific snapshots: "26.3 Snapshot 2".
|
| 85 |
+
mod_id: Optional mod identifier. Currently informational only —
|
| 86 |
+
included in the response payload for caller convenience but does
|
| 87 |
+
not affect resolution.
|
| 88 |
+
|
| 89 |
+
Returns:
|
| 90 |
+
A JSON object with the following fields:
|
| 91 |
+
minecraft_version — the resolved MC version string
|
| 92 |
+
version_type — "release" or "snapshot"
|
| 93 |
+
yarn_mappings — Yarn build string (e.g. "1.21.4+build.8"),
|
| 94 |
+
OR empty string for MC 26.1+ (Yarn
|
| 95 |
+
deprecated; use Mojmap via Loom)
|
| 96 |
+
loader_version — Fabric Loader version
|
| 97 |
+
fabric_api_version — Fabric API mod version from Modrinth
|
| 98 |
+
loom_version — Fabric Loom Gradle plugin version
|
| 99 |
+
gradle_wrapper_version — recommended Gradle wrapper version
|
| 100 |
+
message — human-readable status message
|
| 101 |
+
mod_id — echoed back if provided
|
| 102 |
+
"""
|
| 103 |
+
resolver = _get_resolver()
|
| 104 |
+
config: FabricConfig = await resolver.resolve_config(minecraft_version)
|
| 105 |
+
payload = config.model_dump()
|
| 106 |
+
if mod_id:
|
| 107 |
+
payload["mod_id"] = mod_id
|
| 108 |
+
return payload
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# ---------------------------------------------------------------------------
|
| 112 |
+
# Tool 2: generate_gradle_properties
|
| 113 |
+
# ---------------------------------------------------------------------------
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@mcp.tool()
|
| 117 |
+
async def generate_gradle_properties(
|
| 118 |
+
mod_name: str,
|
| 119 |
+
mod_version: str,
|
| 120 |
+
minecraft_version: str = "latest",
|
| 121 |
+
) -> str:
|
| 122 |
+
"""
|
| 123 |
+
Generate the exact text content of a `gradle.properties` file for a
|
| 124 |
+
Fabric mod, based on the resolved build configuration.
|
| 125 |
+
|
| 126 |
+
Internally calls `get_optimal_fabric_config` to resolve Yarn / Loader /
|
| 127 |
+
Fabric API versions, then renders a complete properties file with both
|
| 128 |
+
mod metadata and Fabric tooling versions.
|
| 129 |
+
|
| 130 |
+
Args:
|
| 131 |
+
mod_name: Human-readable mod name (e.g. "Example Mod").
|
| 132 |
+
mod_version: Mod version string (e.g. "1.0.0").
|
| 133 |
+
minecraft_version: Target MC version, default "latest".
|
| 134 |
+
|
| 135 |
+
Returns:
|
| 136 |
+
The full text content of a `gradle.properties` file, ready to be
|
| 137 |
+
written to disk by the AI assistant.
|
| 138 |
+
"""
|
| 139 |
+
resolver = _get_resolver()
|
| 140 |
+
config: FabricConfig = await resolver.resolve_config(minecraft_version)
|
| 141 |
+
|
| 142 |
+
# Derive a safe mod_id from the mod name: lowercase, replace non-alnum
|
| 143 |
+
# with hyphens, collapse runs of hyphens, strip leading/trailing hyphens.
|
| 144 |
+
raw_id = "".join(c.lower() if c.isalnum() else "-" for c in mod_name)
|
| 145 |
+
while "--" in raw_id:
|
| 146 |
+
raw_id = raw_id.replace("--", "-")
|
| 147 |
+
mod_id = raw_id.strip("-") or "mod"
|
| 148 |
+
|
| 149 |
+
maven_group = f"com.example.{mod_id.replace('-', '.')}"
|
| 150 |
+
archives_base_name = mod_id
|
| 151 |
+
|
| 152 |
+
# For unobfuscated versions (26.1+), Yarn mappings are deprecated and
|
| 153 |
+
# not used — Loom reads Mojang's official Mojmap directly via
|
| 154 |
+
# `loom.officialMojangMappings()` in build.gradle. We omit the
|
| 155 |
+
# `yarn_mappings` line entirely and emit a comment explaining why.
|
| 156 |
+
from resolver import is_unobfuscated
|
| 157 |
+
unobf = is_unobfuscated(config.minecraft_version)
|
| 158 |
+
|
| 159 |
+
mc_fabric_section: list[str] = [
|
| 160 |
+
"# Minecraft & Fabric versions",
|
| 161 |
+
f"minecraft_version = {config.minecraft_version}",
|
| 162 |
+
]
|
| 163 |
+
if unobf:
|
| 164 |
+
mc_fabric_section.append(
|
| 165 |
+
"# yarn_mappings: not used — MC 26.1+ is unobfuscated; mods"
|
| 166 |
+
)
|
| 167 |
+
mc_fabric_section.append(
|
| 168 |
+
"# use Mojang official Mojmap via loom.officialMojangMappings()"
|
| 169 |
+
)
|
| 170 |
+
mc_fabric_section.append(
|
| 171 |
+
"# in build.gradle. (See https://docs.fabricmc.net/develop/porting/mappings)"
|
| 172 |
+
)
|
| 173 |
+
else:
|
| 174 |
+
mc_fabric_section.append(f"yarn_mappings = {config.yarn_mappings}")
|
| 175 |
+
mc_fabric_section.append(f"loader_version = {config.loader_version}")
|
| 176 |
+
mc_fabric_section.append(f"fabric_version = {config.fabric_api_version}")
|
| 177 |
+
|
| 178 |
+
lines: list[str] = [
|
| 179 |
+
"# Gradle properties for Fabric mod",
|
| 180 |
+
"# Generated by fabric-config-oracle MCP server",
|
| 181 |
+
"",
|
| 182 |
+
"# Mod metadata",
|
| 183 |
+
f"mod_name = {mod_name}",
|
| 184 |
+
f"mod_id = {mod_id}",
|
| 185 |
+
f"mod_version = {mod_version}",
|
| 186 |
+
f"maven_group = {maven_group}",
|
| 187 |
+
f"archives_base_name = {archives_base_name}",
|
| 188 |
+
"",
|
| 189 |
+
*mc_fabric_section,
|
| 190 |
+
"",
|
| 191 |
+
"# Build tooling",
|
| 192 |
+
f"loom_version = {config.loom_version}",
|
| 193 |
+
f"gradle_wrapper_version = {config.gradle_wrapper_version}",
|
| 194 |
+
"",
|
| 195 |
+
"# Optional: Kotlin support (uncomment if needed)",
|
| 196 |
+
"# kotlin_code_style = official",
|
| 197 |
+
"# fabric_kotlin_version = 1.10.10+kotlin.1.9.10",
|
| 198 |
+
]
|
| 199 |
+
return "\n".join(lines)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
# ---------------------------------------------------------------------------
|
| 203 |
+
# Resource: gradle_properties schema
|
| 204 |
+
# ---------------------------------------------------------------------------
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
_GRADLE_PROPERTIES_SCHEMA = """\
|
| 208 |
+
# Fabric gradle.properties Schema Reference
|
| 209 |
+
# =========================================
|
| 210 |
+
# This document describes the expected structure of a Fabric mod's
|
| 211 |
+
# gradle.properties file. All keys are case-sensitive.
|
| 212 |
+
#
|
| 213 |
+
# IMPORTANT: Mappings selection depends on the target Minecraft version:
|
| 214 |
+
#
|
| 215 |
+
# * MC <= 1.21.11 (OBFUSCATED): Yarn mappings are REQUIRED. The mod
|
| 216 |
+
# references obfuscated class names which
|
| 217 |
+
# Yarn translates to human-readable names.
|
| 218 |
+
# Loom is configured in build.gradle via
|
| 219 |
+
# mappings "net.fabricmc:yarn:${yarn_mappings}:v2"
|
| 220 |
+
#
|
| 221 |
+
# * MC >= 26.1 (UNOBFUSCATED): Yarn mappings DO NOT EXIST and are
|
| 222 |
+
# NOT NEEDED. Mojang ships the jar
|
| 223 |
+
# unobfuscated with parameter names.
|
| 224 |
+
# Loom is configured in build.gradle via
|
| 225 |
+
# loom.officialMojangMappings()
|
| 226 |
+
# The `yarn_mappings` property line is
|
| 227 |
+
# OMITTED entirely.
|
| 228 |
+
#
|
| 229 |
+
# Source: https://docs.fabricmc.net/develop/porting/mappings
|
| 230 |
+
|
| 231 |
+
# --- Common keys (always present) ---
|
| 232 |
+
|
| 233 |
+
# mod_name: Human-readable mod name (string, any characters)
|
| 234 |
+
mod_name = Example Mod
|
| 235 |
+
|
| 236 |
+
# mod_id: Lowercase identifier, alphanumeric + hyphens only, max 64 chars.
|
| 237 |
+
mod_id = example-mod
|
| 238 |
+
|
| 239 |
+
# mod_version: SemVer-style version string for the mod itself.
|
| 240 |
+
mod_version = 1.0.0
|
| 241 |
+
|
| 242 |
+
# maven_group: Maven group ID for artifact publishing. Conventionally the
|
| 243 |
+
# reversed domain of the mod author.
|
| 244 |
+
maven_group = com.example.examplemod
|
| 245 |
+
|
| 246 |
+
# archives_base_name: Base name for built jar artifacts.
|
| 247 |
+
archives_base_name = example-mod
|
| 248 |
+
|
| 249 |
+
# minecraft_version: Exact MC version this mod targets (e.g. "1.21.4", "26.2").
|
| 250 |
+
minecraft_version = 26.2
|
| 251 |
+
|
| 252 |
+
# loader_version: Fabric Loader version. The mod's minimum compatible loader.
|
| 253 |
+
loader_version = 0.16.0
|
| 254 |
+
|
| 255 |
+
# fabric_version: Fabric API mod version from Modrinth. Required if the mod
|
| 256 |
+
# depends on Fabric API; otherwise can be omitted.
|
| 257 |
+
fabric_version = 0.155.2+26.2
|
| 258 |
+
|
| 259 |
+
# --- Keys present ONLY for obfuscated versions (MC <= 1.21.11) ---
|
| 260 |
+
|
| 261 |
+
# yarn_mappings: Yarn mappings version from meta.fabricmc.net.
|
| 262 |
+
# Format: "<mc_version>+build.<n>".
|
| 263 |
+
# OMITTED for MC 26.1+ (unobfuscated; use Mojmap instead).
|
| 264 |
+
yarn_mappings = 1.21.4+build.8
|
| 265 |
+
|
| 266 |
+
# --- Build tooling (always present) ---
|
| 267 |
+
|
| 268 |
+
# loom_version: Fabric Loom Gradle plugin version (e.g. "1.7-SNAPSHOT").
|
| 269 |
+
loom_version = 1.7-SNAPSHOT
|
| 270 |
+
|
| 271 |
+
# gradle_wrapper_version: Recommended Gradle wrapper version.
|
| 272 |
+
gradle_wrapper_version = 8.8
|
| 273 |
+
|
| 274 |
+
# --- Optional: Kotlin ---
|
| 275 |
+
# kotlin_code_style = official
|
| 276 |
+
# fabric_kotlin_version = 1.10.10+kotlin.1.9.10
|
| 277 |
+
"""
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
@mcp.resource("fabric://config/schema/gradle_properties")
|
| 281 |
+
def get_gradle_properties_schema() -> str:
|
| 282 |
+
"""
|
| 283 |
+
Static reference schema for a Fabric mod `gradle.properties` file.
|
| 284 |
+
|
| 285 |
+
Reading this resource returns a plain-text document describing every
|
| 286 |
+
expected key, its purpose, and an example value. AI assistants can use
|
| 287 |
+
this to validate generated `gradle.properties` files or to learn the
|
| 288 |
+
expected structure before generating one.
|
| 289 |
+
"""
|
| 290 |
+
return _GRADLE_PROPERTIES_SCHEMA
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
# ---------------------------------------------------------------------------
|
| 294 |
+
# Public entry point
|
| 295 |
+
# ---------------------------------------------------------------------------
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def get_server() -> FastMCP:
|
| 299 |
+
"""Return the configured FastMCP server instance."""
|
| 300 |
+
return mcp
|
models.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pydantic data models for the Fabric Config Oracle MCP server.
|
| 3 |
+
|
| 4 |
+
These models define the canonical shape of:
|
| 5 |
+
* A Minecraft version entry parsed from the Mojang version manifest.
|
| 6 |
+
* A fully-resolved Fabric mod build configuration.
|
| 7 |
+
|
| 8 |
+
All MCP tool return values are validated against these models before being
|
| 9 |
+
serialized to JSON, ensuring callers always receive well-formed payloads.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from datetime import datetime
|
| 15 |
+
from typing import Literal, Optional
|
| 16 |
+
|
| 17 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# ---------------------------------------------------------------------------
|
| 21 |
+
# Manifest-level model
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class MinecraftVersion(BaseModel):
|
| 26 |
+
"""
|
| 27 |
+
A single Minecraft version entry extracted from the Mojang manifest.
|
| 28 |
+
|
| 29 |
+
Mojang's manifest exposes every released version (stable releases, snapshots,
|
| 30 |
+
pre-releases, release candidates) along with an ISO-8601 `releaseTime`. We
|
| 31 |
+
use `releaseTime` for sorting because version strings alone are ambiguous
|
| 32 |
+
across the dual versioning schemes (legacy `1.21.x` vs year-based `26.x`).
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
model_config = ConfigDict(frozen=True)
|
| 36 |
+
|
| 37 |
+
id: str = Field(
|
| 38 |
+
...,
|
| 39 |
+
description="The version identifier as published by Mojang, e.g. "
|
| 40 |
+
"'1.21.4', '26.2', or '26.3 Snapshot 2'.",
|
| 41 |
+
)
|
| 42 |
+
type: Literal["release", "snapshot"] = Field(
|
| 43 |
+
...,
|
| 44 |
+
description="Mojang-declared type. Snapshots, pre-releases and "
|
| 45 |
+
"release candidates are all bucketed under 'snapshot' for our "
|
| 46 |
+
"fallback purposes.",
|
| 47 |
+
)
|
| 48 |
+
release_time: datetime = Field(
|
| 49 |
+
...,
|
| 50 |
+
description="ISO-8601 timestamp at which Mojang published the version. "
|
| 51 |
+
"Used for newest-first sorting.",
|
| 52 |
+
)
|
| 53 |
+
url: Optional[str] = Field(
|
| 54 |
+
default=None,
|
| 55 |
+
description="Optional URL to the per-version manifest JSON. "
|
| 56 |
+
"Currently unused but kept for diagnostics.",
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ---------------------------------------------------------------------------
|
| 61 |
+
# Resolved configuration model
|
| 62 |
+
# ---------------------------------------------------------------------------
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class FabricConfig(BaseModel):
|
| 66 |
+
"""
|
| 67 |
+
The fully-resolved Fabric mod build configuration for one Minecraft version.
|
| 68 |
+
|
| 69 |
+
This is the canonical return type of the `get_optimal_fabric_config` MCP
|
| 70 |
+
tool. Every field is populated by the `VersionResolver` after consulting
|
| 71 |
+
the Mojang manifest, Fabric Meta, and Modrinth APIs.
|
| 72 |
+
"""
|
| 73 |
+
|
| 74 |
+
minecraft_version: str = Field(
|
| 75 |
+
...,
|
| 76 |
+
description="The resolved Minecraft version string. May differ from "
|
| 77 |
+
"the requested version when the requested version was 'latest' and "
|
| 78 |
+
"the resolver had to fall back to a Fabric-supported version.",
|
| 79 |
+
)
|
| 80 |
+
version_type: Literal["release", "snapshot"] = Field(
|
| 81 |
+
...,
|
| 82 |
+
description="Whether the resolved minecraft_version is a stable "
|
| 83 |
+
"release or a snapshot.",
|
| 84 |
+
)
|
| 85 |
+
yarn_mappings: str = Field(
|
| 86 |
+
default="",
|
| 87 |
+
description="Yarn mappings build string (e.g. '26.2+build.1'). "
|
| 88 |
+
"Empty string when no Yarn build exists for this MC version.",
|
| 89 |
+
)
|
| 90 |
+
loader_version: str = Field(
|
| 91 |
+
default="",
|
| 92 |
+
description="Fabric Loader version (e.g. '0.16.0').",
|
| 93 |
+
)
|
| 94 |
+
fabric_api_version: str = Field(
|
| 95 |
+
default="",
|
| 96 |
+
description="Fabric API mod version from Modrinth "
|
| 97 |
+
"(e.g. '0.100.0+1.21.4'). Empty when Modrinth has no compatible build.",
|
| 98 |
+
)
|
| 99 |
+
loom_version: str = Field(
|
| 100 |
+
default="1.7-SNAPSHOT",
|
| 101 |
+
description="Fabric Loom Gradle plugin version. Not currently fetched "
|
| 102 |
+
"from a remote source — a sensible default is provided and may be "
|
| 103 |
+
"overridden by the caller.",
|
| 104 |
+
)
|
| 105 |
+
gradle_wrapper_version: str = Field(
|
| 106 |
+
default="8.8",
|
| 107 |
+
description="Recommended Gradle wrapper version. Not currently fetched "
|
| 108 |
+
"from a remote source — a sensible default is provided.",
|
| 109 |
+
)
|
| 110 |
+
message: str = Field(
|
| 111 |
+
default="",
|
| 112 |
+
description="Human-readable status message describing the resolution "
|
| 113 |
+
"outcome, including any missing components.",
|
| 114 |
+
)
|
requirements.txt
CHANGED
|
@@ -1,9 +1,22 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Fabric Config Oracle - MCP Server dependencies
|
| 2 |
+
# Python 3.10+ required (HF Spaces uses 3.12)
|
| 3 |
+
|
| 4 |
+
# Official Model Context Protocol Python SDK (includes FastMCP)
|
| 5 |
+
mcp>=1.0.0
|
| 6 |
+
|
| 7 |
+
# Async HTTP client for Mojang / Fabric Meta / Modrinth API calls
|
| 8 |
+
httpx>=0.27.0
|
| 9 |
+
|
| 10 |
+
# Data validation for config models
|
| 11 |
+
pydantic>=2.0.0
|
| 12 |
+
|
| 13 |
+
# Async compatibility layer used by the MCP SDK
|
| 14 |
+
anyio>=4.0.0
|
| 15 |
+
|
| 16 |
+
# ASGI server — needed for the streamable HTTP transport (`app.py`).
|
| 17 |
+
# Not required for stdio transport (`main.py`).
|
| 18 |
+
uvicorn>=0.30.0
|
| 19 |
+
|
| 20 |
+
# Starlette is pulled in by the MCP SDK as a transitive dep, but we
|
| 21 |
+
# pin a minimum for the `streamable_http_app()` ASGI surface.
|
| 22 |
+
starlette>=0.37.0
|
resolver.py
ADDED
|
@@ -0,0 +1,613 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Version resolver: turns "latest" (or a specific version string) into a
|
| 3 |
+
fully-resolved Fabric build configuration by consulting the Mojang manifest,
|
| 4 |
+
Fabric Meta, and Modrinth APIs.
|
| 5 |
+
|
| 6 |
+
The most subtle part of this module is the **snapshot fallback logic** in
|
| 7 |
+
`VersionResolver.resolve_version`. See its docstring for the rationale.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import asyncio
|
| 13 |
+
import logging
|
| 14 |
+
import re
|
| 15 |
+
from datetime import datetime
|
| 16 |
+
from typing import Any, Optional
|
| 17 |
+
|
| 18 |
+
import httpx
|
| 19 |
+
|
| 20 |
+
from models import FabricConfig, MinecraftVersion
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
# Constants
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
|
| 29 |
+
MOJANG_MANIFEST_URL = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"
|
| 30 |
+
FABRIC_YARN_URL = "https://meta.fabricmc.net/v2/versions/yarn/{mc_version}"
|
| 31 |
+
FABRIC_LOADER_URL = "https://meta.fabricmc.net/v2/versions/loader"
|
| 32 |
+
MODRINTH_FABRIC_API_URL = "https://api.modrinth.com/v2/project/fabric-api/version"
|
| 33 |
+
|
| 34 |
+
# These defaults are not currently fetched from a remote source. They reflect
|
| 35 |
+
# the toolchain versions that work across the MC 1.21 / 26.x range. Override
|
| 36 |
+
# them on the returned FabricConfig if your project requires different values.
|
| 37 |
+
DEFAULT_LOOM_VERSION = "1.7-SNAPSHOT"
|
| 38 |
+
DEFAULT_GRADLE_VERSION = "8.8"
|
| 39 |
+
|
| 40 |
+
# Lowest Minecraft version the oracle will consider for "latest" resolution.
|
| 41 |
+
MINIMUM_MC_VERSION_ID = "1.21"
|
| 42 |
+
|
| 43 |
+
# Cache TTLs
|
| 44 |
+
MANIFEST_CACHE_TTL_SECONDS = 600 # 10 minutes — manifest doesn't change often
|
| 45 |
+
|
| 46 |
+
# Safety caps so a runaway loop never hammers the upstream APIs.
|
| 47 |
+
MAX_SNAPSHOTS_TO_PROBE = 20
|
| 48 |
+
MAX_RELEASES_TO_PROBE = 10
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ---------------------------------------------------------------------------
|
| 52 |
+
# Version string parsing
|
| 53 |
+
# ---------------------------------------------------------------------------
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# Match the space form "26.3 Snapshot 2" OR the dash form "26.3-snapshot-2"
|
| 57 |
+
# (the dash form is what Mojang's manifest actually publishes).
|
| 58 |
+
_SNAPSHOT_RE = re.compile(
|
| 59 |
+
r"^\s*(\d+)\.(\d+)(?:\.(\d+))?\s*[-\s]+[Ss]napshot\s*[-\s]*(\d+)\s*$"
|
| 60 |
+
)
|
| 61 |
+
# Pre-release and release candidate forms: "26.2-pre-6", "26.2-rc-1".
|
| 62 |
+
# Treated as snapshots for sorting (below the corresponding release).
|
| 63 |
+
_PRE_RE = re.compile(
|
| 64 |
+
r"^\s*(\d+)\.(\d+)(?:\.(\d+))?\s*[-\s]+(?:pre|rc)\s*[-\s]*(\d+)\s*$"
|
| 65 |
+
)
|
| 66 |
+
_RELEASE_RE = re.compile(r"^\s*(\d+)\.(\d+)(?:\.(\d+))?\s*$")
|
| 67 |
+
|
| 68 |
+
# Legacy weekly-snapshot format like "23w13a", "25w46a". Used by Mojang for
|
| 69 |
+
# older snapshots. We need to recognize these so we can correctly *exclude*
|
| 70 |
+
# pre-1.21 ones from the "latest" candidate pool.
|
| 71 |
+
_WEEKLY_SNAPSHOT_RE = re.compile(r"^\s*(\d{2})w(\d{1,2})[a-z]\s*$")
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def parse_mc_version(version_id: str) -> tuple[int, ...]:
|
| 75 |
+
"""
|
| 76 |
+
Parse a Minecraft version string into a comparable tuple.
|
| 77 |
+
|
| 78 |
+
Minecraft recently switched from `1.21.x` style versioning to year-based
|
| 79 |
+
`26.x` versioning. A standard semver parser would treat `1.21.4` as
|
| 80 |
+
*newer* than `26.2` because semver would parse `1` and `26` as the major
|
| 81 |
+
version. We instead want `26.2` to be newer, because year 2026 follows
|
| 82 |
+
year 2024 (when `1.21` shipped).
|
| 83 |
+
|
| 84 |
+
The trick: keep the numeric components in tuple order. Python's tuple
|
| 85 |
+
comparison walks left-to-right, so `(26, 2, 0, ...)` > `(1, 21, 0, ...)`
|
| 86 |
+
because `26 > 1`. No special "year offset" is needed.
|
| 87 |
+
|
| 88 |
+
Snapshots are sorted *below* their corresponding release by appending a
|
| 89 |
+
type marker (`1` for snapshot, `2` for release) followed by the snapshot
|
| 90 |
+
number for snapshots. This means:
|
| 91 |
+
`26.3 Snapshot 2` < `26.3 Snapshot 10` < `26.3` (release)
|
| 92 |
+
|
| 93 |
+
Supports three input forms (all observed in Mojang's manifest):
|
| 94 |
+
- "26.3 Snapshot 2" (space form, user-facing)
|
| 95 |
+
- "26.3-snapshot-2" (dash form, manifest id)
|
| 96 |
+
- "26.2-pre-6" (dash form, pre-release)
|
| 97 |
+
- "26.2-rc-1" (dash form, release candidate)
|
| 98 |
+
|
| 99 |
+
Returns a tuple of ints. Unparseable strings return (0,) so they always
|
| 100 |
+
sort below any real version — this is intentional, because the resolver's
|
| 101 |
+
`is_minimum_version` filter then excludes them from the "latest" pool.
|
| 102 |
+
"""
|
| 103 |
+
snap = _SNAPSHOT_RE.match(version_id)
|
| 104 |
+
if snap:
|
| 105 |
+
major = int(snap.group(1))
|
| 106 |
+
minor = int(snap.group(2))
|
| 107 |
+
patch = int(snap.group(3)) if snap.group(3) else 0
|
| 108 |
+
snap_num = int(snap.group(4))
|
| 109 |
+
# type marker: 1 = snapshot (lower than release's 2)
|
| 110 |
+
return (major, minor, patch, 1, snap_num)
|
| 111 |
+
|
| 112 |
+
pre = _PRE_RE.match(version_id)
|
| 113 |
+
if pre:
|
| 114 |
+
major = int(pre.group(1))
|
| 115 |
+
minor = int(pre.group(2))
|
| 116 |
+
patch = int(pre.group(3)) if pre.group(3) else 0
|
| 117 |
+
pre_num = int(pre.group(4))
|
| 118 |
+
# Treat pre-releases as snapshots for ordering purposes.
|
| 119 |
+
return (major, minor, patch, 1, pre_num)
|
| 120 |
+
|
| 121 |
+
rel = _RELEASE_RE.match(version_id)
|
| 122 |
+
if rel:
|
| 123 |
+
major = int(rel.group(1))
|
| 124 |
+
minor = int(rel.group(2))
|
| 125 |
+
patch = int(rel.group(3)) if rel.group(3) else 0
|
| 126 |
+
# type marker: 2 = release
|
| 127 |
+
return (major, minor, patch, 2)
|
| 128 |
+
|
| 129 |
+
# Legacy weekly snapshots like "23w13a", "25w46a". Map them onto the
|
| 130 |
+
# (year, week) tuple. Year 23 == 2023, week 13. We use the same 4-tuple
|
| 131 |
+
# shape with a sentinel marker so they sort *above* 1.21 if year>=24 but
|
| 132 |
+
# below 1.21 otherwise. (24wXXa corresponds to MC 1.21 snapshots.)
|
| 133 |
+
weekly = _WEEKLY_SNAPSHOT_RE.match(version_id)
|
| 134 |
+
if weekly:
|
| 135 |
+
year_short = int(weekly.group(1))
|
| 136 |
+
week = int(weekly.group(2))
|
| 137 |
+
# Weekly snapshots sort *below* the corresponding release: use type
|
| 138 |
+
# marker 1. Major version = full year (e.g. 23 -> 2023) so 23w13a
|
| 139 |
+
# (2023) sorts below 1.21 (which is (1, 21, 0, 2)).
|
| 140 |
+
return (2000 + year_short, week, 0, 1)
|
| 141 |
+
|
| 142 |
+
# Truly unparseable: return (0,) so it sorts below everything.
|
| 143 |
+
return (0,)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def is_minimum_version(version_id: str, minimum: str = MINIMUM_MC_VERSION_ID) -> bool:
|
| 147 |
+
"""
|
| 148 |
+
True iff `version_id` >= `minimum` under our custom parser.
|
| 149 |
+
|
| 150 |
+
For minimum "1.21", this rejects:
|
| 151 |
+
- Legacy weekly snapshots from before 1.21's release (mid-2024).
|
| 152 |
+
Weekly snapshots live in a separate "epoch" (year + week) that
|
| 153 |
+
doesn't tuple-compare cleanly against `1.x` / `26.x` versions, so
|
| 154 |
+
we apply a separate cutoff: weekly snapshots at or after week 24
|
| 155 |
+
of 2024 (when 1.21 dropped) are considered "above 1.21".
|
| 156 |
+
- Truly unparseable strings (which return (0,)).
|
| 157 |
+
"""
|
| 158 |
+
# Special case: legacy weekly snapshots (YYwNNa format). 1.21 was
|
| 159 |
+
# released on 2024-06-13 (ISO week 24). Snapshots at or after that
|
| 160 |
+
# point are experimental snapshots for 1.21.x+ content.
|
| 161 |
+
weekly = _WEEKLY_SNAPSHOT_RE.match(version_id)
|
| 162 |
+
if weekly:
|
| 163 |
+
year_short = int(weekly.group(1))
|
| 164 |
+
week = int(weekly.group(2))
|
| 165 |
+
return (year_short, week) >= (24, 24)
|
| 166 |
+
|
| 167 |
+
try:
|
| 168 |
+
return parse_mc_version(version_id) >= parse_mc_version(minimum)
|
| 169 |
+
except Exception:
|
| 170 |
+
return False
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
# Minecraft 26.1 (released March 2026) is the first release where Mojang
|
| 174 |
+
# shipped the Java jar *unobfuscated*, with parameter names and (limited)
|
| 175 |
+
# Javadoc-style comments included. As a consequence, the Fabric Project
|
| 176 |
+
# officially retired Yarn mappings — Yarn builds simply don't exist for
|
| 177 |
+
# 26.1+. Mods targeting 26.1+ use Mojang's official mappings directly via
|
| 178 |
+
# Loom's `loom.officialMojangMappings()` call in build.gradle, and the
|
| 179 |
+
# `gradle.properties` file does NOT carry a `yarn_mappings` line.
|
| 180 |
+
#
|
| 181 |
+
# Source: https://docs.fabricmc.net/develop/porting/mappings
|
| 182 |
+
# "Minecraft 26.1 is unobfuscated and includes parameter names,
|
| 183 |
+
# so there is no need for any obfuscation mappings."
|
| 184 |
+
UNOBFUSCATED_MINIMUM = "26.1"
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def is_unobfuscated(version_id: str) -> bool:
|
| 188 |
+
"""
|
| 189 |
+
True iff `version_id` is an unobfuscated Minecraft release (>= 26.1).
|
| 190 |
+
|
| 191 |
+
For these versions:
|
| 192 |
+
- Yarn mappings do NOT exist on Fabric Meta and never will.
|
| 193 |
+
- The Fabric ecosystem (Yarn, Loader, Fabric API) still works — only
|
| 194 |
+
the mappings layer is replaced by Mojang's official Mojmap.
|
| 195 |
+
- Loom reads Mojmap via `loom.officialMojangMappings()` in build.gradle
|
| 196 |
+
instead of `mappings "net.fabricmc:yarn:..."`.
|
| 197 |
+
|
| 198 |
+
The resolver uses this to decide whether Yarn is a hard requirement
|
| 199 |
+
when probing for "latest" support.
|
| 200 |
+
"""
|
| 201 |
+
# Weekly snapshots from 2025+ (25wXXa) are 1.21.x dev cycle, still
|
| 202 |
+
# obfuscated. Only year-based 26.1+ versions are unobfuscated.
|
| 203 |
+
if _WEEKLY_SNAPSHOT_RE.match(version_id):
|
| 204 |
+
return False
|
| 205 |
+
try:
|
| 206 |
+
return parse_mc_version(version_id) >= parse_mc_version(UNOBFUSCATED_MINIMUM)
|
| 207 |
+
except Exception:
|
| 208 |
+
return False
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
# ---------------------------------------------------------------------------
|
| 212 |
+
# Resolver
|
| 213 |
+
# ---------------------------------------------------------------------------
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
class VersionResolver:
|
| 217 |
+
"""
|
| 218 |
+
Resolves the optimal Fabric configuration for a Minecraft version.
|
| 219 |
+
|
| 220 |
+
## Snapshot fallback logic
|
| 221 |
+
|
| 222 |
+
When `target_version` is `"latest"` (or omitted), the resolver must be
|
| 223 |
+
*intelligent* about which version it picks. Minecraft snapshots are
|
| 224 |
+
released frequently by Mojang, but the Fabric ecosystem (Fabric API mod
|
| 225 |
+
for all versions; Yarn mappings only for *obfuscated* versions) often
|
| 226 |
+
lags by days. If we blindly returned the newest snapshot, the AI might
|
| 227 |
+
generate a `gradle.properties` that references Fabric API versions that
|
| 228 |
+
*do not exist yet*, and the user's mod would fail to build.
|
| 229 |
+
|
| 230 |
+
The resolver therefore:
|
| 231 |
+
|
| 232 |
+
1. Fetches the Mojang manifest and keeps only versions >= `1.21`.
|
| 233 |
+
2. Sorts them by `releaseTime` descending (newest first).
|
| 234 |
+
3. Iterates the newest snapshots, one by one. For each, it asks
|
| 235 |
+
`has_fabric_support(mc_version)`, which:
|
| 236 |
+
- For OBFUSCATED versions (1.21.x and earlier): requires BOTH
|
| 237 |
+
Yarn mappings AND a Fabric API build.
|
| 238 |
+
- For UNOBFUSCATED versions (26.1+, per Mojang's Oct 2025
|
| 239 |
+
announcement and Fabric's deprecation of Yarn): requires ONLY
|
| 240 |
+
a Fabric API build. Yarn doesn't exist for these versions
|
| 241 |
+
and is not needed — mods use Mojang's official Mojmap via
|
| 242 |
+
Loom's `loom.officialMojangMappings()`.
|
| 243 |
+
If support is confirmed, that snapshot is returned immediately.
|
| 244 |
+
4. If none of the probed snapshots have support, the resolver repeats
|
| 245 |
+
the same check against the newest stable releases.
|
| 246 |
+
5. If even releases have no support (extremely unlikely — would
|
| 247 |
+
indicate a Fabric ecosystem outage), it returns the newest release
|
| 248 |
+
as a last-resort so the tool can still produce a useful error.
|
| 249 |
+
|
| 250 |
+
Iteration is *sequential* not parallel. This is intentional: the typical
|
| 251 |
+
case is that the 1st or 2nd snapshot already has support, so we make at
|
| 252 |
+
most 2-3 API round-trips total. Parallel probing would always make N
|
| 253 |
+
round-trips and risk rate-limiting on Modrinth.
|
| 254 |
+
|
| 255 |
+
See: https://docs.fabricmc.net/develop/porting/mappings
|
| 256 |
+
"""
|
| 257 |
+
|
| 258 |
+
def __init__(self, http_client: Optional[httpx.AsyncClient] = None) -> None:
|
| 259 |
+
# A shared client gives us connection pooling and HTTP/2 multiplexing.
|
| 260 |
+
self._client: httpx.AsyncClient = http_client or httpx.AsyncClient(
|
| 261 |
+
timeout=httpx.Timeout(30.0),
|
| 262 |
+
headers={"User-Agent": "fabric-config-oracle/1.0"},
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
# In-process caches. They live for the lifetime of the resolver
|
| 266 |
+
# (i.e. for the lifetime of the MCP server process). The manifest
|
| 267 |
+
# cache additionally has a TTL; the per-version caches do not, but
|
| 268 |
+
# they're small (one entry per probed MC version) and never grow
|
| 269 |
+
# unboundedly because we cap probes via MAX_*_TO_PROBE.
|
| 270 |
+
self._manifest_cache: Optional[dict[str, Any]] = None
|
| 271 |
+
self._manifest_cache_time: Optional[datetime] = None
|
| 272 |
+
self._loader_cache: Optional[list[dict[str, Any]]] = None
|
| 273 |
+
self._yarn_cache: dict[str, Optional[dict[str, Any]]] = {}
|
| 274 |
+
self._fabric_api_cache: dict[str, Optional[dict[str, Any]]] = {}
|
| 275 |
+
|
| 276 |
+
# -- lifecycle --------------------------------------------------------
|
| 277 |
+
|
| 278 |
+
async def aclose(self) -> None:
|
| 279 |
+
"""Close the underlying HTTP client. Safe to call multiple times."""
|
| 280 |
+
await self._client.aclose()
|
| 281 |
+
|
| 282 |
+
async def __aenter__(self) -> "VersionResolver":
|
| 283 |
+
return self
|
| 284 |
+
|
| 285 |
+
async def __aexit__(self, *_: object) -> None:
|
| 286 |
+
await self.aclose()
|
| 287 |
+
|
| 288 |
+
# -- low-level HTTP ---------------------------------------------------
|
| 289 |
+
|
| 290 |
+
async def _fetch_json(
|
| 291 |
+
self,
|
| 292 |
+
url: str,
|
| 293 |
+
params: Optional[dict[str, Any]] = None,
|
| 294 |
+
) -> Any:
|
| 295 |
+
"""
|
| 296 |
+
Fetch JSON from `url` with graceful error handling.
|
| 297 |
+
|
| 298 |
+
Returns:
|
| 299 |
+
- The parsed JSON value on success.
|
| 300 |
+
- `None` on 404 (treated as "no data available").
|
| 301 |
+
- `None` on any other error after logging.
|
| 302 |
+
|
| 303 |
+
429 (rate limited) responses trigger a single backoff-and-retry.
|
| 304 |
+
"""
|
| 305 |
+
try:
|
| 306 |
+
response = await self._client.get(url, params=params)
|
| 307 |
+
response.raise_for_status()
|
| 308 |
+
return response.json()
|
| 309 |
+
except httpx.HTTPStatusError as exc:
|
| 310 |
+
status = exc.response.status_code
|
| 311 |
+
if status == 404:
|
| 312 |
+
return None
|
| 313 |
+
if status == 429:
|
| 314 |
+
logger.warning("Rate limited by %s; backing off 2s and retrying once.", url)
|
| 315 |
+
await asyncio.sleep(2.0)
|
| 316 |
+
try:
|
| 317 |
+
retry = await self._client.get(url, params=params)
|
| 318 |
+
retry.raise_for_status()
|
| 319 |
+
return retry.json()
|
| 320 |
+
except Exception as retry_exc: # noqa: BLE001
|
| 321 |
+
logger.error("Retry of %s failed: %s", url, retry_exc)
|
| 322 |
+
return None
|
| 323 |
+
logger.error("HTTP %d from %s: %s", status, url, exc)
|
| 324 |
+
return None
|
| 325 |
+
except (httpx.RequestError, ValueError) as exc:
|
| 326 |
+
logger.error("Error fetching %s: %s", url, exc)
|
| 327 |
+
return None
|
| 328 |
+
|
| 329 |
+
# -- API wrappers -----------------------------------------------------
|
| 330 |
+
|
| 331 |
+
async def fetch_mojang_manifest(self) -> list[MinecraftVersion]:
|
| 332 |
+
"""Fetch and parse the Mojang manifest, filtered to versions >= 1.21."""
|
| 333 |
+
now = datetime.now()
|
| 334 |
+
if (
|
| 335 |
+
self._manifest_cache is not None
|
| 336 |
+
and self._manifest_cache_time is not None
|
| 337 |
+
and (now - self._manifest_cache_time).total_seconds() < MANIFEST_CACHE_TTL_SECONDS
|
| 338 |
+
):
|
| 339 |
+
return self._parse_manifest(self._manifest_cache)
|
| 340 |
+
|
| 341 |
+
data = await self._fetch_json(MOJANG_MANIFEST_URL)
|
| 342 |
+
if data is None:
|
| 343 |
+
if self._manifest_cache is not None:
|
| 344 |
+
logger.warning("Mojang manifest fetch failed; using stale cache.")
|
| 345 |
+
return self._parse_manifest(self._manifest_cache)
|
| 346 |
+
logger.error("Mojang manifest unavailable and no cache to fall back on.")
|
| 347 |
+
return []
|
| 348 |
+
|
| 349 |
+
self._manifest_cache = data
|
| 350 |
+
self._manifest_cache_time = now
|
| 351 |
+
return self._parse_manifest(data)
|
| 352 |
+
|
| 353 |
+
def _parse_manifest(self, data: dict[str, Any]) -> list[MinecraftVersion]:
|
| 354 |
+
"""Parse raw manifest JSON into a list of MinecraftVersion objects."""
|
| 355 |
+
out: list[MinecraftVersion] = []
|
| 356 |
+
for v in data.get("versions", []):
|
| 357 |
+
vid = v.get("id", "")
|
| 358 |
+
vtype = v.get("type", "")
|
| 359 |
+
# Only release/snapshot types are relevant to us. Mojang also
|
| 360 |
+
# exposes 'old_beta' / 'old_alpha' which we deliberately skip.
|
| 361 |
+
if vtype not in ("release", "snapshot"):
|
| 362 |
+
continue
|
| 363 |
+
if not is_minimum_version(vid):
|
| 364 |
+
continue
|
| 365 |
+
try:
|
| 366 |
+
rt_raw = v["releaseTime"]
|
| 367 |
+
rt = datetime.fromisoformat(rt_raw.replace("Z", "+00:00"))
|
| 368 |
+
except (KeyError, ValueError):
|
| 369 |
+
continue
|
| 370 |
+
out.append(
|
| 371 |
+
MinecraftVersion(
|
| 372 |
+
id=vid,
|
| 373 |
+
type=vtype, # type: ignore[arg-type]
|
| 374 |
+
release_time=rt,
|
| 375 |
+
url=v.get("url"),
|
| 376 |
+
)
|
| 377 |
+
)
|
| 378 |
+
return out
|
| 379 |
+
|
| 380 |
+
async def fetch_yarn(self, mc_version: str) -> Optional[dict[str, Any]]:
|
| 381 |
+
"""Fetch the newest Yarn mappings build for `mc_version`, or None."""
|
| 382 |
+
if mc_version in self._yarn_cache:
|
| 383 |
+
return self._yarn_cache[mc_version]
|
| 384 |
+
|
| 385 |
+
data = await self._fetch_json(FABRIC_YARN_URL.format(mc_version=mc_version))
|
| 386 |
+
if not isinstance(data, list) or len(data) == 0:
|
| 387 |
+
self._yarn_cache[mc_version] = None
|
| 388 |
+
return None
|
| 389 |
+
|
| 390 |
+
# Fabric Meta returns Yarn builds newest-first, but sort defensively.
|
| 391 |
+
data.sort(key=lambda y: y.get("build", 0), reverse=True)
|
| 392 |
+
yarn = data[0]
|
| 393 |
+
self._yarn_cache[mc_version] = yarn
|
| 394 |
+
return yarn
|
| 395 |
+
|
| 396 |
+
async def fetch_loader(self) -> Optional[dict[str, Any]]:
|
| 397 |
+
"""Fetch the newest stable Fabric Loader version."""
|
| 398 |
+
if self._loader_cache is not None:
|
| 399 |
+
return self._loader_cache[0] if self._loader_cache else None
|
| 400 |
+
|
| 401 |
+
data = await self._fetch_json(FABRIC_LOADER_URL)
|
| 402 |
+
if not isinstance(data, list) or len(data) == 0:
|
| 403 |
+
return None
|
| 404 |
+
|
| 405 |
+
self._loader_cache = data
|
| 406 |
+
# First entry is the latest stable loader per Fabric Meta convention.
|
| 407 |
+
return data[0]
|
| 408 |
+
|
| 409 |
+
async def fetch_fabric_api(self, mc_version: str) -> Optional[dict[str, Any]]:
|
| 410 |
+
"""Fetch the newest Fabric API mod build for `mc_version` from Modrinth."""
|
| 411 |
+
if mc_version in self._fabric_api_cache:
|
| 412 |
+
return self._fabric_api_cache[mc_version]
|
| 413 |
+
|
| 414 |
+
# Modrinth expects JSON-encoded array query params, e.g.
|
| 415 |
+
# ?game_versions=["1.21.4"]&loaders=["fabric"]
|
| 416 |
+
params = {
|
| 417 |
+
"game_versions": f'["{mc_version}"]',
|
| 418 |
+
"loaders": '["fabric"]',
|
| 419 |
+
}
|
| 420 |
+
data = await self._fetch_json(MODRINTH_FABRIC_API_URL, params=params)
|
| 421 |
+
if not isinstance(data, list) or len(data) == 0:
|
| 422 |
+
self._fabric_api_cache[mc_version] = None
|
| 423 |
+
return None
|
| 424 |
+
|
| 425 |
+
data.sort(key=lambda v: v.get("date_published", ""), reverse=True)
|
| 426 |
+
fabric_api = data[0]
|
| 427 |
+
self._fabric_api_cache[mc_version] = fabric_api
|
| 428 |
+
return fabric_api
|
| 429 |
+
|
| 430 |
+
async def has_fabric_support(self, mc_version: str) -> bool:
|
| 431 |
+
"""
|
| 432 |
+
True iff `mc_version` has the Fabric dependencies required to build
|
| 433 |
+
a working mod.
|
| 434 |
+
|
| 435 |
+
## Unobfuscated versions (26.1+)
|
| 436 |
+
|
| 437 |
+
As of Minecraft 26.1 (March 2026), Mojang ships Java jars
|
| 438 |
+
unobfuscated, so Yarn mappings are deprecated and no longer
|
| 439 |
+
published. Mods targeting 26.1+ use Mojang's official Mojmap
|
| 440 |
+
directly via Loom's `loom.officialMojangMappings()`. For these
|
| 441 |
+
versions, the only Fabric dependency we need to verify is Fabric
|
| 442 |
+
API on Modrinth.
|
| 443 |
+
|
| 444 |
+
## Obfuscated versions (<= 1.21.11)
|
| 445 |
+
|
| 446 |
+
For these, BOTH Yarn AND Fabric API must exist, since a mod
|
| 447 |
+
referencing obfuscated class names cannot compile without Yarn
|
| 448 |
+
(or another mapping set) to translate them.
|
| 449 |
+
|
| 450 |
+
See: https://docs.fabricmc.net/develop/porting/mappings
|
| 451 |
+
"""
|
| 452 |
+
if is_unobfuscated(mc_version):
|
| 453 |
+
# Only Fabric API required; Yarn doesn't exist for 26.1+.
|
| 454 |
+
fabric_api = await self.fetch_fabric_api(mc_version)
|
| 455 |
+
return fabric_api is not None
|
| 456 |
+
|
| 457 |
+
# Obfuscated path: Yarn + Fabric API both required.
|
| 458 |
+
yarn, fabric_api = await asyncio.gather(
|
| 459 |
+
self.fetch_yarn(mc_version),
|
| 460 |
+
self.fetch_fabric_api(mc_version),
|
| 461 |
+
)
|
| 462 |
+
return yarn is not None and fabric_api is not None
|
| 463 |
+
|
| 464 |
+
# -- resolution -------------------------------------------------------
|
| 465 |
+
|
| 466 |
+
async def resolve_version(
|
| 467 |
+
self,
|
| 468 |
+
target_version: str = "latest",
|
| 469 |
+
) -> Optional[MinecraftVersion]:
|
| 470 |
+
"""
|
| 471 |
+
Resolve a target Minecraft version, applying fallback logic for "latest".
|
| 472 |
+
|
| 473 |
+
See the `VersionResolver` class docstring for the full snapshot
|
| 474 |
+
fallback rationale.
|
| 475 |
+
|
| 476 |
+
Args:
|
| 477 |
+
target_version: Either "latest" (default) or a specific version
|
| 478 |
+
string like "1.21.4", "26.2", or "26.3 Snapshot 2".
|
| 479 |
+
|
| 480 |
+
Returns:
|
| 481 |
+
A `MinecraftVersion` if resolution succeeded, else `None`.
|
| 482 |
+
|
| 483 |
+
For specific (non-"latest") requests, the version is looked up in the
|
| 484 |
+
manifest without a support check. If it isn't in the manifest, `None`
|
| 485 |
+
is returned and the caller may still attempt to fetch dependencies for
|
| 486 |
+
it — the per-API wrappers will return empty results gracefully.
|
| 487 |
+
"""
|
| 488 |
+
versions = await self.fetch_mojang_manifest()
|
| 489 |
+
if not versions:
|
| 490 |
+
return None
|
| 491 |
+
|
| 492 |
+
# Direct (specific-version) request — no fallback logic.
|
| 493 |
+
if target_version and target_version.lower() != "latest":
|
| 494 |
+
for v in versions:
|
| 495 |
+
if v.id == target_version:
|
| 496 |
+
return v
|
| 497 |
+
# Case-insensitive fallback (some users mistype casing).
|
| 498 |
+
for v in versions:
|
| 499 |
+
if v.id.lower() == target_version.lower():
|
| 500 |
+
return v
|
| 501 |
+
# Not in manifest. Return None so the caller can still try to
|
| 502 |
+
# fetch dependencies directly — Fabric Meta / Modrinth might
|
| 503 |
+
# know about it even if Mojang's manifest hasn't updated yet.
|
| 504 |
+
return None
|
| 505 |
+
|
| 506 |
+
# "latest" — apply snapshot-first, release-fallback resolution.
|
| 507 |
+
versions_sorted = sorted(versions, key=lambda v: v.release_time, reverse=True)
|
| 508 |
+
|
| 509 |
+
# Step 3: probe newest snapshots.
|
| 510 |
+
snapshots = [v for v in versions_sorted if v.type == "snapshot"]
|
| 511 |
+
for snap in snapshots[:MAX_SNAPSHOTS_TO_PROBE]:
|
| 512 |
+
if await self.has_fabric_support(snap.id):
|
| 513 |
+
return snap
|
| 514 |
+
|
| 515 |
+
# Step 4: fall back to newest stable releases.
|
| 516 |
+
releases = [v for v in versions_sorted if v.type == "release"]
|
| 517 |
+
for rel in releases[:MAX_RELEASES_TO_PROBE]:
|
| 518 |
+
if await self.has_fabric_support(rel.id):
|
| 519 |
+
return rel
|
| 520 |
+
|
| 521 |
+
# Step 5: last resort — return the newest release even without
|
| 522 |
+
# confirmed support, so we can surface a meaningful error message
|
| 523 |
+
# via FabricConfig.message.
|
| 524 |
+
return releases[0] if releases else (snapshots[0] if snapshots else None)
|
| 525 |
+
|
| 526 |
+
async def resolve_config(self, target_version: str = "latest") -> FabricConfig:
|
| 527 |
+
"""
|
| 528 |
+
Resolve the full Fabric configuration for a target MC version.
|
| 529 |
+
|
| 530 |
+
This is the high-level entry point used by the MCP tool. It first
|
| 531 |
+
resolves the version (applying fallback logic if "latest"), then
|
| 532 |
+
fetches Yarn, Loader, and Fabric API versions concurrently.
|
| 533 |
+
|
| 534 |
+
The returned `FabricConfig.message` field always describes the
|
| 535 |
+
outcome — success, partial success, or failure — in human-readable
|
| 536 |
+
form so the AI caller can react accordingly.
|
| 537 |
+
"""
|
| 538 |
+
mc_version = await self.resolve_version(target_version)
|
| 539 |
+
if mc_version is None:
|
| 540 |
+
return FabricConfig(
|
| 541 |
+
minecraft_version=target_version,
|
| 542 |
+
version_type="release",
|
| 543 |
+
yarn_mappings="",
|
| 544 |
+
loader_version="",
|
| 545 |
+
fabric_api_version="",
|
| 546 |
+
loom_version=DEFAULT_LOOM_VERSION,
|
| 547 |
+
gradle_wrapper_version=DEFAULT_GRADLE_VERSION,
|
| 548 |
+
message=(
|
| 549 |
+
f"Could not resolve Minecraft version '{target_version}'. "
|
| 550 |
+
"It may not exist in the Mojang manifest, or the manifest "
|
| 551 |
+
"could not be fetched."
|
| 552 |
+
),
|
| 553 |
+
)
|
| 554 |
+
|
| 555 |
+
# For unobfuscated versions (26.1+), Yarn does not exist and is not
|
| 556 |
+
# needed — mods use Mojang's official Mojmap directly via Loom. Skip
|
| 557 |
+
# the Yarn fetch entirely to avoid a wasted 404 round-trip.
|
| 558 |
+
unobf = is_unobfuscated(mc_version.id)
|
| 559 |
+
|
| 560 |
+
if unobf:
|
| 561 |
+
loader, fabric_api = await asyncio.gather(
|
| 562 |
+
self.fetch_loader(),
|
| 563 |
+
self.fetch_fabric_api(mc_version.id),
|
| 564 |
+
)
|
| 565 |
+
yarn = None
|
| 566 |
+
else:
|
| 567 |
+
yarn, loader, fabric_api = await asyncio.gather(
|
| 568 |
+
self.fetch_yarn(mc_version.id),
|
| 569 |
+
self.fetch_loader(),
|
| 570 |
+
self.fetch_fabric_api(mc_version.id),
|
| 571 |
+
)
|
| 572 |
+
|
| 573 |
+
yarn_version = (yarn or {}).get("version", "")
|
| 574 |
+
loader_version = (loader or {}).get("version", "")
|
| 575 |
+
# Modrinth's `version_number` is the human-facing string
|
| 576 |
+
# (e.g. "0.100.0+1.21.4"). `id` is the URL-safe UUID.
|
| 577 |
+
fabric_api_version = (fabric_api or {}).get("version_number", "")
|
| 578 |
+
|
| 579 |
+
# Build the missing-deps list. For unobfuscated versions, Yarn is
|
| 580 |
+
# intentionally absent — it's not "missing", it's deprecated.
|
| 581 |
+
missing: list[str] = []
|
| 582 |
+
if not unobf and not yarn_version:
|
| 583 |
+
missing.append("Yarn mappings")
|
| 584 |
+
if not loader_version:
|
| 585 |
+
missing.append("Fabric Loader")
|
| 586 |
+
if not fabric_api_version:
|
| 587 |
+
missing.append("Fabric API")
|
| 588 |
+
|
| 589 |
+
if missing:
|
| 590 |
+
message = (
|
| 591 |
+
f"Configuration partially resolved for MC {mc_version.id}. "
|
| 592 |
+
f"Missing: {', '.join(missing)}."
|
| 593 |
+
)
|
| 594 |
+
elif unobf:
|
| 595 |
+
message = (
|
| 596 |
+
f"Configuration resolved successfully for MC {mc_version.id}. "
|
| 597 |
+
"Yarn mappings are not required (Minecraft 26.1+ ships "
|
| 598 |
+
"unobfuscated; mods use Mojang official Mojmap via Loom's "
|
| 599 |
+
"`loom.officialMojangMappings()` in build.gradle)."
|
| 600 |
+
)
|
| 601 |
+
else:
|
| 602 |
+
message = "Configuration resolved successfully."
|
| 603 |
+
|
| 604 |
+
return FabricConfig(
|
| 605 |
+
minecraft_version=mc_version.id,
|
| 606 |
+
version_type=mc_version.type,
|
| 607 |
+
yarn_mappings=yarn_version,
|
| 608 |
+
loader_version=loader_version,
|
| 609 |
+
fabric_api_version=fabric_api_version,
|
| 610 |
+
loom_version=DEFAULT_LOOM_VERSION,
|
| 611 |
+
gradle_wrapper_version=DEFAULT_GRADLE_VERSION,
|
| 612 |
+
message=message,
|
| 613 |
+
)
|
smoke_test_http.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
End-to-end smoke test for the streamable HTTP deployment of the
|
| 3 |
+
fabric-config-oracle MCP server.
|
| 4 |
+
|
| 5 |
+
What it does:
|
| 6 |
+
1. Starts `app.py` as a background subprocess on port 7860.
|
| 7 |
+
2. Waits for the server to come up (polls /mcp).
|
| 8 |
+
3. Issues a real JSON-RPC sequence over HTTP:
|
| 9 |
+
initialize → tools/list → tools/call (both tools) → resources/list → resources/read
|
| 10 |
+
4. Asserts that every step returns the expected shape.
|
| 11 |
+
5. Tears down the subprocess.
|
| 12 |
+
|
| 13 |
+
This proves the HF Space deployment will actually work when clients
|
| 14 |
+
connect to https://<space>.hf.space/mcp.
|
| 15 |
+
|
| 16 |
+
Run:
|
| 17 |
+
python scripts/smoke_test_http.py
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import json
|
| 23 |
+
import os
|
| 24 |
+
import signal
|
| 25 |
+
import subprocess
|
| 26 |
+
import sys
|
| 27 |
+
import time
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
import httpx
|
| 31 |
+
|
| 32 |
+
# --- Configuration -------------------------------------------------------
|
| 33 |
+
|
| 34 |
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
| 35 |
+
APP_DIR = PROJECT_ROOT / "fabric_config_mcp"
|
| 36 |
+
HOST = "127.0.0.1"
|
| 37 |
+
PORT = 7860
|
| 38 |
+
BASE_URL = f"http://{HOST}:{PORT}"
|
| 39 |
+
MCP_URL = f"{BASE_URL}/mcp"
|
| 40 |
+
VENV_PYTHON = PROJECT_ROOT / ".venv" / "bin" / "python"
|
| 41 |
+
|
| 42 |
+
# --- Helpers -------------------------------------------------------------
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def wait_for_server(timeout: float = 30.0) -> bool:
|
| 46 |
+
"""Poll the MCP endpoint until it responds (any HTTP status counts)."""
|
| 47 |
+
deadline = time.monotonic() + timeout
|
| 48 |
+
while time.monotonic() < deadline:
|
| 49 |
+
try:
|
| 50 |
+
r = httpx.post(
|
| 51 |
+
MCP_URL,
|
| 52 |
+
json={
|
| 53 |
+
"jsonrpc": "2.0",
|
| 54 |
+
"id": 0,
|
| 55 |
+
"method": "initialize",
|
| 56 |
+
"params": {
|
| 57 |
+
"protocolVersion": "2025-06-18",
|
| 58 |
+
"capabilities": {},
|
| 59 |
+
"clientInfo": {"name": "smoke-test", "version": "0.0.1"},
|
| 60 |
+
},
|
| 61 |
+
},
|
| 62 |
+
headers={
|
| 63 |
+
"Content-Type": "application/json",
|
| 64 |
+
"Accept": "application/json, text/event-stream",
|
| 65 |
+
},
|
| 66 |
+
timeout=3.0,
|
| 67 |
+
)
|
| 68 |
+
# Any HTTP response means the server is up.
|
| 69 |
+
return r.status_code in (200, 406, 400)
|
| 70 |
+
except Exception:
|
| 71 |
+
time.sleep(0.5)
|
| 72 |
+
return False
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def parse_response(r: httpx.Response) -> dict:
|
| 76 |
+
"""
|
| 77 |
+
The server may return either:
|
| 78 |
+
- application/json → the JSON-RPC envelope directly
|
| 79 |
+
- text/event-stream → a single `data: {...}` line containing the envelope
|
| 80 |
+
Parse both.
|
| 81 |
+
"""
|
| 82 |
+
ct = r.headers.get("content-type", "")
|
| 83 |
+
if "application/json" in ct:
|
| 84 |
+
return r.json()
|
| 85 |
+
if "text/event-stream" in ct:
|
| 86 |
+
# Read all lines, pick the first `data:` line that parses as JSON.
|
| 87 |
+
for line in r.text.splitlines():
|
| 88 |
+
line = line.strip()
|
| 89 |
+
if line.startswith("data:"):
|
| 90 |
+
payload = line[len("data:"):].strip()
|
| 91 |
+
try:
|
| 92 |
+
return json.loads(payload)
|
| 93 |
+
except json.JSONDecodeError:
|
| 94 |
+
continue
|
| 95 |
+
raise RuntimeError(f"SSE response had no parseable data: line. Body: {r.text!r}")
|
| 96 |
+
raise RuntimeError(f"Unexpected content-type {ct!r}. Body: {r.text!r}")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def mcp_call(client: httpx.Client, method: str, params: dict | None = None, req_id: int = 1) -> dict:
|
| 100 |
+
"""Send a single JSON-RPC request and return the parsed result envelope."""
|
| 101 |
+
payload: dict = {"jsonrpc": "2.0", "id": req_id, "method": method}
|
| 102 |
+
if params is not None:
|
| 103 |
+
payload["params"] = params
|
| 104 |
+
r = client.post(
|
| 105 |
+
MCP_URL,
|
| 106 |
+
json=payload,
|
| 107 |
+
headers={
|
| 108 |
+
"Content-Type": "application/json",
|
| 109 |
+
"Accept": "application/json, text/event-stream",
|
| 110 |
+
},
|
| 111 |
+
timeout=60.0, # first call hits upstream APIs; give it room
|
| 112 |
+
)
|
| 113 |
+
assert r.status_code == 200, f"HTTP {r.status_code} for {method}: {r.text}"
|
| 114 |
+
return parse_response(r)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def ok(env: dict, label: str) -> dict:
|
| 118 |
+
"""Assert JSON-RPC success and return the `result` object."""
|
| 119 |
+
assert "error" not in env, f"{label} returned JSON-RPC error: {env.get('error')}"
|
| 120 |
+
assert "result" in env, f"{label} missing `result`: {env}"
|
| 121 |
+
print(f" [OK] {label}")
|
| 122 |
+
return env["result"]
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# --- Main test -----------------------------------------------------------
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def main() -> int:
|
| 129 |
+
print("=" * 70)
|
| 130 |
+
print("fabric-config-oracle — streamable HTTP smoke test")
|
| 131 |
+
print("=" * 70)
|
| 132 |
+
|
| 133 |
+
# 1. Start server
|
| 134 |
+
print(f"\n[1/6] Starting app.py on {BASE_URL} ...")
|
| 135 |
+
env = os.environ.copy()
|
| 136 |
+
env["FASTMCP_HOST"] = HOST
|
| 137 |
+
env["FASTMCP_PORT"] = str(PORT)
|
| 138 |
+
env["PYTHONPATH"] = str(APP_DIR)
|
| 139 |
+
proc = subprocess.Popen(
|
| 140 |
+
[str(VENV_PYTHON), "app.py"],
|
| 141 |
+
cwd=str(APP_DIR),
|
| 142 |
+
env=env,
|
| 143 |
+
stdout=subprocess.PIPE,
|
| 144 |
+
stderr=subprocess.STDOUT,
|
| 145 |
+
)
|
| 146 |
+
try:
|
| 147 |
+
if not wait_for_server():
|
| 148 |
+
# Server didn't come up — dump its output for debugging.
|
| 149 |
+
out = proc.stdout.read().decode(errors="replace") if proc.stdout else ""
|
| 150 |
+
print("ERROR: server did not start. Output:")
|
| 151 |
+
print(out)
|
| 152 |
+
return 1
|
| 153 |
+
print(" [OK] server is up")
|
| 154 |
+
|
| 155 |
+
with httpx.Client(base_url=BASE_URL) as client:
|
| 156 |
+
# 2. initialize
|
| 157 |
+
print("\n[2/6] Sending initialize ...")
|
| 158 |
+
init_result = ok(
|
| 159 |
+
mcp_call(
|
| 160 |
+
client,
|
| 161 |
+
"initialize",
|
| 162 |
+
{
|
| 163 |
+
"protocolVersion": "2025-06-18",
|
| 164 |
+
"capabilities": {},
|
| 165 |
+
"clientInfo": {"name": "smoke-test", "version": "0.0.1"},
|
| 166 |
+
},
|
| 167 |
+
req_id=1,
|
| 168 |
+
),
|
| 169 |
+
"initialize",
|
| 170 |
+
)
|
| 171 |
+
server_info = init_result.get("serverInfo", {})
|
| 172 |
+
print(f" server name: {server_info.get('name')!r}")
|
| 173 |
+
print(f" protocol: {init_result.get('protocolVersion')}")
|
| 174 |
+
|
| 175 |
+
# Send initialized notification (no response expected, but spec requires it)
|
| 176 |
+
client.post(
|
| 177 |
+
MCP_URL,
|
| 178 |
+
json={"jsonrpc": "2.0", "method": "notifications/initialized"},
|
| 179 |
+
headers={
|
| 180 |
+
"Content-Type": "application/json",
|
| 181 |
+
"Accept": "application/json, text/event-stream",
|
| 182 |
+
},
|
| 183 |
+
timeout=10.0,
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
# 3. tools/list
|
| 187 |
+
print("\n[3/6] Listing tools ...")
|
| 188 |
+
tools_result = ok(mcp_call(client, "tools/list", {}, req_id=2), "tools/list")
|
| 189 |
+
tool_names = sorted(t["name"] for t in tools_result.get("tools", []))
|
| 190 |
+
print(f" tools: {tool_names}")
|
| 191 |
+
assert "get_optimal_fabric_config" in tool_names, "missing tool: get_optimal_fabric_config"
|
| 192 |
+
assert "generate_gradle_properties" in tool_names, "missing tool: generate_gradle_properties"
|
| 193 |
+
|
| 194 |
+
# 4. tools/call — get_optimal_fabric_config (latest)
|
| 195 |
+
print("\n[4/6] Calling get_optimal_fabric_config(version='latest') ...")
|
| 196 |
+
cfg_result = ok(
|
| 197 |
+
mcp_call(
|
| 198 |
+
client,
|
| 199 |
+
"tools/call",
|
| 200 |
+
{
|
| 201 |
+
"name": "get_optimal_fabric_config",
|
| 202 |
+
"arguments": {"minecraft_version": "latest", "mod_id": "smoke-test"},
|
| 203 |
+
},
|
| 204 |
+
req_id=3,
|
| 205 |
+
),
|
| 206 |
+
"tools/call(get_optimal_fabric_config, latest)",
|
| 207 |
+
)
|
| 208 |
+
# The result.content is a list of content blocks; the JSON payload
|
| 209 |
+
# is in the first text block.
|
| 210 |
+
text_blocks = [c for c in cfg_result.get("content", []) if c.get("type") == "text"]
|
| 211 |
+
assert text_blocks, f"no text content in result: {cfg_result}"
|
| 212 |
+
cfg_payload = json.loads(text_blocks[0]["text"])
|
| 213 |
+
print(f" minecraft_version: {cfg_payload.get('minecraft_version')}")
|
| 214 |
+
print(f" version_type: {cfg_payload.get('version_type')}")
|
| 215 |
+
print(f" yarn_mappings: {cfg_payload.get('yarn_mappings')!r}")
|
| 216 |
+
print(f" loader_version: {cfg_payload.get('loader_version')}")
|
| 217 |
+
print(f" fabric_api_version: {cfg_payload.get('fabric_api_version')}")
|
| 218 |
+
print(f" loom_version: {cfg_payload.get('loom_version')}")
|
| 219 |
+
print(f" message: {cfg_payload.get('message')[:80]}...")
|
| 220 |
+
assert cfg_payload.get("minecraft_version"), "minecraft_version missing"
|
| 221 |
+
assert cfg_payload.get("loader_version"), "loader_version missing"
|
| 222 |
+
assert cfg_payload.get("fabric_api_version"), "fabric_api_version missing"
|
| 223 |
+
assert cfg_payload.get("mod_id") == "smoke-test", "mod_id not echoed"
|
| 224 |
+
|
| 225 |
+
# 5. tools/call — get_optimal_fabric_config (1.21.4, obfuscated)
|
| 226 |
+
print("\n[5/6] Calling get_optimal_fabric_config(version='1.21.4') (obfuscated) ...")
|
| 227 |
+
cfg2_result = ok(
|
| 228 |
+
mcp_call(
|
| 229 |
+
client,
|
| 230 |
+
"tools/call",
|
| 231 |
+
{
|
| 232 |
+
"name": "get_optimal_fabric_config",
|
| 233 |
+
"arguments": {"minecraft_version": "1.21.4"},
|
| 234 |
+
},
|
| 235 |
+
req_id=4,
|
| 236 |
+
),
|
| 237 |
+
"tools/call(get_optimal_fabric_config, 1.21.4)",
|
| 238 |
+
)
|
| 239 |
+
text_blocks = [c for c in cfg2_result.get("content", []) if c.get("type") == "text"]
|
| 240 |
+
cfg2 = json.loads(text_blocks[0]["text"])
|
| 241 |
+
print(f" minecraft_version: {cfg2.get('minecraft_version')}")
|
| 242 |
+
print(f" yarn_mappings: {cfg2.get('yarn_mappings')}")
|
| 243 |
+
assert cfg2.get("yarn_mappings"), "1.21.4 should have Yarn mappings!"
|
| 244 |
+
|
| 245 |
+
# 6. tools/call — generate_gradle_properties
|
| 246 |
+
print("\n[6/6] Calling generate_gradle_properties(mod_name='Demo Mod', mod_version='1.0.0', minecraft_version='latest') ...")
|
| 247 |
+
grad_result = ok(
|
| 248 |
+
mcp_call(
|
| 249 |
+
client,
|
| 250 |
+
"tools/call",
|
| 251 |
+
{
|
| 252 |
+
"name": "generate_gradle_properties",
|
| 253 |
+
"arguments": {
|
| 254 |
+
"mod_name": "Demo Mod",
|
| 255 |
+
"mod_version": "1.0.0",
|
| 256 |
+
"minecraft_version": "latest",
|
| 257 |
+
},
|
| 258 |
+
},
|
| 259 |
+
req_id=5,
|
| 260 |
+
),
|
| 261 |
+
"tools/call(generate_gradle_properties)",
|
| 262 |
+
)
|
| 263 |
+
text_blocks = [c for c in grad_result.get("content", []) if c.get("type") == "text"]
|
| 264 |
+
grad_text = text_blocks[0]["text"]
|
| 265 |
+
print(" --- gradle.properties (first 30 lines) ---")
|
| 266 |
+
for line in grad_text.splitlines()[:30]:
|
| 267 |
+
print(f" | {line}")
|
| 268 |
+
print(" ---")
|
| 269 |
+
assert "minecraft_version" in grad_text
|
| 270 |
+
assert "loader_version" in grad_text
|
| 271 |
+
assert "fabric_version" in grad_text
|
| 272 |
+
assert "loom_version" in grad_text
|
| 273 |
+
|
| 274 |
+
# 7. resources/list + resources/read (bonus)
|
| 275 |
+
print("\n[bonus] Reading fabric://config/schema/gradle_properties resource ...")
|
| 276 |
+
list_result = ok(mcp_call(client, "resources/list", {}, req_id=6), "resources/list")
|
| 277 |
+
uris = [r.get("uri") for r in list_result.get("resources", [])]
|
| 278 |
+
print(f" resource URIs: {uris}")
|
| 279 |
+
assert "fabric://config/schema/gradle_properties" in uris
|
| 280 |
+
|
| 281 |
+
read_result = ok(
|
| 282 |
+
mcp_call(
|
| 283 |
+
client,
|
| 284 |
+
"resources/read",
|
| 285 |
+
{"uri": "fabric://config/schema/gradle_properties"},
|
| 286 |
+
req_id=7,
|
| 287 |
+
),
|
| 288 |
+
"resources/read",
|
| 289 |
+
)
|
| 290 |
+
text_blocks = [c for c in read_result.get("contents", []) if "text" in c]
|
| 291 |
+
schema_text = text_blocks[0]["text"] if text_blocks else ""
|
| 292 |
+
print(f" schema length: {len(schema_text)} chars")
|
| 293 |
+
assert "yarn_mappings" in schema_text
|
| 294 |
+
assert "Mojmap" in schema_text or "Mojang" in schema_text
|
| 295 |
+
|
| 296 |
+
print("\n" + "=" * 70)
|
| 297 |
+
print("ALL SMOKE TESTS PASSED")
|
| 298 |
+
print("=" * 70)
|
| 299 |
+
return 0
|
| 300 |
+
finally:
|
| 301 |
+
# Tear down: send SIGTERM, then SIGKILL if it doesn't die in 5s.
|
| 302 |
+
print("\n[cleanup] shutting down server subprocess ...")
|
| 303 |
+
proc.send_signal(signal.SIGTERM)
|
| 304 |
+
try:
|
| 305 |
+
proc.wait(timeout=5.0)
|
| 306 |
+
except subprocess.TimeoutExpired:
|
| 307 |
+
proc.kill()
|
| 308 |
+
proc.wait()
|
| 309 |
+
print(" [OK] server stopped")
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
if __name__ == "__main__":
|
| 313 |
+
sys.exit(main())
|