ZeroClawAgent commited on
Commit
d984ed3
·
verified ·
1 Parent(s): c029994

Upload 10 files

Browse files
Files changed (10) hide show
  1. .dockerignore +21 -0
  2. Dockerfile +42 -5
  3. README.md +180 -5
  4. app.py +119 -0
  5. main.py +35 -0
  6. mcp_server.py +300 -0
  7. models.py +114 -0
  8. requirements.txt +22 -9
  9. resolver.py +613 -0
  10. 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
- FROM python:3.11-slim
 
 
 
 
 
 
 
 
2
 
3
- WORKDIR /app
4
 
5
- COPY requirements.txt .
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  RUN pip install --no-cache-dir -r requirements.txt
7
 
8
- COPY server.py .
 
9
 
 
 
 
 
10
  EXPOSE 7860
11
 
12
- CMD ["python", "server.py"]
 
 
 
 
 
 
 
 
 
 
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: ACE-Step 1.5 API
3
- emoji: 🎵
4
- colorFrom: purple
5
- colorTo: pink
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
- fastapi
2
- uvicorn
3
- pydantic
4
- numpy
5
- torch
6
- torchaudio
7
- transformers
8
- accelerate
9
- librosa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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())