Skip to content

Commit f6d66a6

Browse files
Maselkovclaude
andauthored
MCP endpoint: AI assistants can read the base (#199)
* Move the craft, cancel and stock-rule checks out of the routes The MCP endpoint coming next must check what the page's routes check, so the logic moves into functions both can call: - commands.request_cancel(): the busy-CPU and same-job checks. The job identity helpers (output_identity, same_output) move with it from tracking, which imports commands, not the other way round. - commands.craft_request_error(): the craft request's input checks. - stock.item_from(), set_alert(), set_target() and rules(): stock rule validation, the craftable check and the rules listing. - planner.rules_for_user(): the stock rules a plan warns about. No change to what the routes accept or answer, except that a craft request's amount can no longer be true (which counted as 1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * MCP endpoint: AI assistants can read the base /mcp is a Model Context Protocol endpoint, so Claude Code or another MCP client can answer questions about the base. Eleven read-only tools: item search, item details and history, inventory changes, current crafts, a job's held-up steps, craft history and stats, the crafting plan, stock rules and power. - Hand-written JSON-RPC over Streamable HTTP (gcm/mcp/protocol.py): one JSON answer per POST, no sessions. It serves both protocol eras: the 2026-07-28 stateless requests (version in _meta, mirrored headers checked) and initialize-based 2025-03-26..2025-11-25 clients. - Signed in with the user's personal access token as a bearer token (auth.bearer_required), never a session cookie; another site's Origin is refused. - Tools take item keys from search_items, never names, and compact their output for a model's context. - docs: an AI assistants section in using.md, plus README, server.md, how-it-works.md and CONTEXT.md. dev.py prints the MCP address. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * MCP: review fixes for the endpoint and read tools - A tool that raises anything but ToolError answers as a tool error (and is logged) instead of a 500 the client can't read, and a KeyError inside a tool no longer reads as "Unknown tool". - Numbers in arguments must be finite: JSON's 1e400 is infinity to Python, which int() turned into a 500. - inventory_changes builds the snapshot's lookup once instead of scanning it for every changed item. - search_items ranks by the name without Minecraft's colour codes, and tool output drops them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Maselkov <25772111+Maselkov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent bebd52f commit f6d66a6

16 files changed

Lines changed: 1441 additions & 2 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ Basically, it's web AE2 for your base.
3131
to another machine, or needing a different fluid, circuit or coil.
3232
- **[Webhooks](docs/using.md#webhooks)** — finished pinned crafts and failed
3333
auto-crafts posted to a Discord channel or any other webhook.
34+
- **[AI assistants](docs/using.md#ai-assistants)** — an MCP endpoint, so
35+
Claude or another AI assistant can answer questions about the base: what's
36+
running low, why a craft is slow, what a craft needs.
3437
- **[Accounts](docs/server.md#user-accounts)** — per-user tokens with
3538
viewer, operator, and admin roles, and a
3639
[private](docs/server.md#private-servers) setting so only signed-in
@@ -176,7 +179,7 @@ docker compose exec gtnh-craft-monitor python app.py new-token "Your name"
176179
- [In-game setup](docs/in-game.md) — requirements, installing and updating
177180
the scripts, their settings
178181
- [Using the web page](docs/using.md) — every tab, search syntax, crafting,
179-
stock rules, notifications, webhooks
182+
stock rules, notifications, webhooks, AI assistants
180183
- [How it works](docs/how-it-works.md) — how the pieces connect, and what
181184
the scripts read from AE2 and GregTech, and why
182185
- [Icon export](tools/icon-export/README.md) — building game data for

‎docs/CONTEXT.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,23 @@ color alone differentiates the highlighted terms now.
778778

779779
## Design decisions worth knowing (not bugs, but non-obvious choices)
780780

781+
**The MCP endpoint is hand-written, stateless and dual-era.** `/mcp`
782+
(`gcm/mcp/`) doesn't use the MCP Python SDK: the SDK is ASGI and this
783+
server is WSGI, and an endpoint that answers every request with one JSON
784+
object, keeps no sessions and only offers tools is a few hundred lines of
785+
JSON-RPC. It serves both protocol eras on one URL. A 2026-07-28 request
786+
carries its version in `params._meta` and the `MCP-Protocol-Version`
787+
header, which must match, as must `Mcp-Method`/`Mcp-Name`. An `initialize`
788+
request picks a 2025-03-26..2025-11-25 version, and later legacy requests
789+
name it in the header; as nothing is kept between requests, each is served
790+
by its header alone (2025-03-26 without one). Tools never take an item
791+
name: `search_items` returns item keys and the other tools take those, so
792+
a model can't act on the wrong one of several items with the same name.
793+
Tool output is compacted for a model's context (`tools._compact_numbers`,
794+
no icons, capped lists, ISO times). Auth is `auth.bearer_required`: the
795+
user's personal access token in an `Authorization: Bearer` header,
796+
never a session cookie, so a browser can't be tricked into calling it.
797+
781798
**Progress % counts steps, not time.** The server computes it
782799
(`gcm/progress.py`, called from `tracking.process_jobs`), not
783800
craft_monitor.lua. A step is one item or fluid type in the job's

‎docs/how-it-works.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ verified against the GTNH OpenComputers source and live testing.
2525
└───────────────────────────┘
2626
```
2727

28+
AI assistants reach the same data through `/mcp`, an MCP endpoint
29+
([AI assistants](using.md#ai-assistants)). Its tools call the code the
30+
page's routes do, with the signed-in user's role.
31+
2832
The game always initiates the connection: scripts POST their readings and
2933
poll the server for pending craft requests and cancellations. The server
3034
never connects into the game.

‎docs/server.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@ which is only appropriate on a trusted LAN. To expose it to the internet:
140140
5. Use an `https://` `SERVER_URL` in `/home/config.lua` so the API key isn't sent
141141
in cleartext.
142142

143+
Proxy the whole site, not just the page: AI assistants use `/mcp` (see
144+
[AI assistants](using.md#ai-assistants)).
145+
143146
The server accepts up to 500 open connections. A proxy keeps idle
144147
connections to it open for reuse, and opening a page full of icons can make
145148
it open many at once. If the server log says `total open connections reached

‎docs/using.md‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,47 @@ Click a row to open that item. Grid cells with a rule carry a bell (alert) or
440440
circular-arrows (target) badge, tinted red while low, and the item's chart
441441
draws each threshold as a dashed line.
442442

443+
## AI assistants
444+
445+
The server is also an [MCP](https://modelcontextprotocol.io) server, so an AI
446+
assistant can read the base and answer questions about it: "what's been
447+
draining fastest this week?", "why is the craft on M00 slow?", "what's
448+
missing to make 64 of these?". Its address is your page's address with
449+
`/mcp` on the end, e.g. `https://monitor.example.com/mcp`.
450+
451+
The assistant signs in with your personal access token, so it sees what you
452+
see. It can't change anything yet: every tool reads.
453+
454+
With [Claude Code](https://claude.com/claude-code):
455+
456+
```bash
457+
claude mcp add --transport http gtnh https://monitor.example.com/mcp \
458+
--header "Authorization: Bearer gcm_tok-..."
459+
```
460+
461+
Other MCP clients that can send a header work the same way: the endpoint
462+
speaks Streamable HTTP and wants `Authorization: Bearer <your token>`.
463+
Anyone holding the token can use it as you, so keep it out of shared config
464+
files, and use **New token** in Manage users if it leaks.
465+
466+
What the assistant can look at:
467+
468+
| Tool | What it answers |
469+
|---|---|
470+
| `search_items` | Items, fluids and essentia by name ([NEI-style search](#search)), with amounts |
471+
| `get_item` | One item: amount, trend, tooltip, stock rules, the CPUs making it |
472+
| `item_history` | An item's amount over the last hour to its whole history |
473+
| `inventory_changes` | What's being used up or piling up over a day or a week |
474+
| `current_crafts` | Each CPU's job and progress, and which are idle |
475+
| `craft_job` | A running job's steps, held-up ones first, with the machines holding them up |
476+
| `craft_history` | Jobs that ended |
477+
| `craft_stats` | Totals, most crafted items, CPU use, slowest machines |
478+
| `plan_craft` | The [crafting plan](#requesting-and-cancelling-crafts) for an amount of an item |
479+
| `stock_rules` | Keep-in-stock targets and your alerts |
480+
| `power` | Stored EU over time and its trend |
481+
482+
Answers are only as fresh as the game's last report, the same as the page's.
483+
443484
## Limitations
444485

445486
- Crafting status is only as fresh as `POLL_SECONDS` (5 s by default) plus the

‎server/dev.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1063,6 +1063,7 @@ def run(args, data_dir):
10631063
url = f"http://{'127.0.0.1' if args.host == '0.0.0.0' else args.host}:{args.port}/"
10641064
print(f"\n GTNH Monitor dev server: {url}\n"
10651065
f" Admin access token: {token}\n"
1066+
f" MCP endpoint: {url}mcp (Authorization: Bearer <the token>)\n"
10661067
f" Data directory: {data_dir}{'' if args.data_dir else ' (deleted on exit)'}\n",
10671068
flush=True)
10681069
if args.open:

‎server/gcm/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
agents, auth, backup, bottlenecks, charts, commands, config, db, gamedata, gamedata_check, inventory, job_tree, lenient_json, patterns,
88
planner, push, recipes, retention, security, state, store, webhooks,
99
)
10-
from gcm.routes import agent, craft_requests, crafts, network, pages, power, users
10+
from gcm.routes import agent, craft_requests, crafts, mcp, network, pages, power, users
1111
from gcm.routes import backup as backup_routes
1212
from gcm.routes import gamedata as gamedata_routes
1313
from gcm.routes import push as push_routes
@@ -44,6 +44,7 @@ def create_app(data_dir=None, api_key=None):
4444
push_routes.bp,
4545
agent.bp,
4646
backup_routes.bp,
47+
mcp.bp,
4748
pages.bp,
4849
):
4950
app.register_blueprint(blueprint)

‎server/gcm/auth.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
@auth.viewer_required the base's data: anyone, or on a private
1313
server (is_private()) any signed-in user;
1414
g.user is the user or None
15+
@auth.bearer_required an access token in an Authorization: Bearer
16+
header, not a session (/mcp); g.user is the
17+
token's user
1518
@auth.public no check, deliberately
1619
@auth.custom_check checked inside the view - say why there
1720
@@ -142,6 +145,25 @@ def _check_login():
142145
return None
143146

144147

148+
def bearer_user():
149+
"""The user an Authorization: Bearer header's access token signs in,
150+
or None."""
151+
header = request.headers.get("Authorization", "")
152+
scheme, _, token = header.partition(" ")
153+
if scheme.lower() != "bearer" or not token.strip():
154+
return None
155+
return store.users.token_user(token.strip())
156+
157+
158+
def _check_bearer():
159+
g.user = bearer_user()
160+
if not g.user:
161+
response = jsonify({"error": "authentication required"})
162+
response.headers["WWW-Authenticate"] = 'Bearer realm="gtnh-craft-monitor"'
163+
return response, 401
164+
return None
165+
166+
145167
def _check_viewer():
146168
g.user = session_user()
147169
if not g.user and is_private():
@@ -168,5 +190,6 @@ def _check_admin():
168190
viewer_required = _policy("viewer", _check_viewer)
169191
operator_required = _policy("operator", _check_operator)
170192
admin_required = _policy("admin", _check_admin)
193+
bearer_required = _policy("bearer", _check_bearer)
171194
public = _policy("public")
172195
custom_check = _policy("custom")

‎server/gcm/mcp/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
"""The MCP endpoint (/mcp): gcm.mcp.protocol speaks the protocol,
2+
gcm.mcp.tools has the tools. routes/mcp.py serves it."""

‎server/gcm/mcp/protocol.py‎

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
"""The Model Context Protocol over Streamable HTTP, as /mcp speaks it:
2+
JSON-RPC, one message per POST, every answer a single JSON object
3+
(never an SSE stream), and no sessions. Tools are all it offers.
4+
5+
Written here rather than with the MCP Python SDK: the SDK is ASGI and
6+
this server is WSGI (Flask on waitress), and without streams or
7+
sessions the protocol is small.
8+
9+
Two eras of the protocol are served on the one endpoint (the spec's
10+
"dual-era" server):
11+
12+
- Modern (2026-07-28): no handshake. Each request names its version in
13+
params._meta and in the MCP-Protocol-Version header, which must agree,
14+
as must the Mcp-Method and Mcp-Name headers with the body. Results
15+
carry resultType and the server's identity in _meta.
16+
- Legacy (2025-03-26 to 2025-11-25): an initialize request picks the
17+
version, and later requests name it in the MCP-Protocol-Version header.
18+
As no session is kept, a request is served by its header alone.
19+
20+
handle() takes the parsed body and the headers and returns (HTTP
21+
status, JSON body or None); routes/mcp.py does the HTTP and the auth."""
22+
23+
import base64
24+
import binascii
25+
import json
26+
27+
from gcm import config
28+
from gcm.mcp import tools
29+
30+
MODERN_VERSIONS = ["2026-07-28"]
31+
LEGACY_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26"]
32+
SUPPORTED_VERSIONS = MODERN_VERSIONS + LEGACY_VERSIONS
33+
34+
_META_VERSION = "io.modelcontextprotocol/protocolVersion"
35+
_META_SERVER_INFO = "io.modelcontextprotocol/serverInfo"
36+
37+
PARSE_ERROR = -32700
38+
INVALID_REQUEST = -32600
39+
METHOD_NOT_FOUND = -32601
40+
INVALID_PARAMS = -32602
41+
HEADER_MISMATCH = -32020
42+
UNSUPPORTED_VERSION = -32022
43+
44+
INSTRUCTIONS = (
45+
"This server is a Gregtech: New Horizons (Minecraft modpack) base's monitor. It reads the base's "
46+
"AE2 ME network, crafting CPUs and power storage, as reported by OpenComputers scripts in the game. "
47+
"Start with search_items to find an item's key; the other item tools take that key, never a name. "
48+
"Amounts are items, or mB (millibuckets, the game's litres) for fluids. Data is as fresh as the "
49+
"game's last report: say when it is old."
50+
)
51+
52+
53+
def server_info():
54+
return {"name": "gtnh-craft-monitor", "title": "GTNH Craft Monitor", "version": config.SERVER_VERSION or "dev"}
55+
56+
57+
def error(msg_id, code, message, data=None):
58+
error = {"code": code, "message": message}
59+
if data is not None:
60+
error["data"] = data
61+
return {"jsonrpc": "2.0", "id": msg_id, "error": error}
62+
63+
64+
def _result(msg_id, result):
65+
return {"jsonrpc": "2.0", "id": msg_id, "result": result}
66+
67+
68+
def _decode_header(value):
69+
"""A header value with the spec's =?base64?...?= encoding undone."""
70+
if value is not None and value.startswith("=?base64?") and value.endswith("?="):
71+
try:
72+
return base64.b64decode(value[9:-2], validate=True).decode("utf-8")
73+
except (binascii.Error, UnicodeDecodeError):
74+
return None
75+
return value
76+
77+
78+
def _unsupported(msg_id, requested):
79+
return 400, error(
80+
msg_id, UNSUPPORTED_VERSION, "Unsupported protocol version",
81+
{"supported": SUPPORTED_VERSIONS, "requested": requested},
82+
)
83+
84+
85+
def handle(message, headers, user):
86+
"""Answers one JSON-RPC message for a signed-in user."""
87+
if not isinstance(message, dict) or message.get("jsonrpc") != "2.0" or not isinstance(message.get("method"), str):
88+
if isinstance(message, list):
89+
return 400, error(None, INVALID_REQUEST, "Batches aren't supported: send one message per request")
90+
return 400, error(None, INVALID_REQUEST, "Not a JSON-RPC 2.0 request")
91+
method = message["method"]
92+
params = message.get("params")
93+
if params is None:
94+
params = {}
95+
if not isinstance(params, dict):
96+
return 400, error(message.get("id"), INVALID_PARAMS, "params must be an object")
97+
98+
if "id" not in message:
99+
# Notifications (the legacy notifications/initialized and
100+
# notifications/cancelled): nothing to do for any of them.
101+
return 202, None
102+
msg_id = message["id"]
103+
104+
meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {}
105+
header_version = headers.get("MCP-Protocol-Version")
106+
107+
if method == "initialize":
108+
requested = params.get("protocolVersion")
109+
version = requested if requested in LEGACY_VERSIONS else LEGACY_VERSIONS[0]
110+
return 200, _result(msg_id, {
111+
"protocolVersion": version,
112+
"capabilities": {"tools": {"listChanged": False}},
113+
"serverInfo": server_info(),
114+
"instructions": INSTRUCTIONS,
115+
})
116+
117+
if _META_VERSION in meta:
118+
version = meta[_META_VERSION]
119+
if header_version is None:
120+
return 400, error(msg_id, HEADER_MISMATCH, "Header mismatch: MCP-Protocol-Version header is missing")
121+
if header_version != version:
122+
return 400, error(
123+
msg_id, HEADER_MISMATCH,
124+
f"Header mismatch: MCP-Protocol-Version header value '{header_version}' does not match body value '{version}'",
125+
)
126+
if version not in MODERN_VERSIONS:
127+
return _unsupported(msg_id, version)
128+
mismatch = _header_mismatch(method, params, headers)
129+
if mismatch:
130+
return 400, error(msg_id, HEADER_MISMATCH, mismatch)
131+
modern = True
132+
else:
133+
# Legacy: the version initialize settled on, in the header; a
134+
# 2025-03-26 client didn't send one.
135+
version = header_version or "2025-03-26"
136+
if version not in LEGACY_VERSIONS:
137+
return _unsupported(msg_id, version)
138+
modern = False
139+
140+
if method == "server/discover":
141+
return 200, _modern_result(msg_id, {
142+
"supportedVersions": SUPPORTED_VERSIONS,
143+
"capabilities": {"tools": {}},
144+
"instructions": INSTRUCTIONS,
145+
"ttlMs": 3600000,
146+
"cacheScope": "public",
147+
})
148+
if method == "ping":
149+
result = {}
150+
elif method == "tools/list":
151+
result = {"tools": tools.listing()}
152+
if modern:
153+
# The same list for everyone, but per-user once there are
154+
# tools only some roles may use.
155+
result.update({"ttlMs": 3600000, "cacheScope": "private"})
156+
elif method == "tools/call":
157+
name = params.get("name")
158+
if not isinstance(name, str):
159+
return 200, error(msg_id, INVALID_PARAMS, "params.name must be a tool name")
160+
if not tools.exists(name):
161+
return 200, error(msg_id, INVALID_PARAMS, f"Unknown tool: {name}")
162+
answer, is_error = tools.call(name, params.get("arguments"), user)
163+
result = {"content": [{"type": "text", "text": _compact_json(answer)}], "isError": is_error}
164+
else:
165+
return (404 if modern else 200), error(msg_id, METHOD_NOT_FOUND, f"Method not found: {method}")
166+
return 200, (_modern_result(msg_id, result) if modern else _result(msg_id, result))
167+
168+
169+
def _modern_result(msg_id, result):
170+
return _result(msg_id, {"resultType": "complete", **result, "_meta": {_META_SERVER_INFO: server_info()}})
171+
172+
173+
def _header_mismatch(method, params, headers):
174+
"""Why the modern request headers don't agree with the body, or None."""
175+
header_method = headers.get("Mcp-Method")
176+
if header_method is None:
177+
return "Header mismatch: Mcp-Method header is missing"
178+
if header_method != method:
179+
return f"Header mismatch: Mcp-Method header value '{header_method}' does not match body value '{method}'"
180+
if method == "tools/call":
181+
raw = headers.get("Mcp-Name")
182+
if raw is None:
183+
return "Header mismatch: Mcp-Name header is missing"
184+
name = _decode_header(raw)
185+
if name != params.get("name"):
186+
return f"Header mismatch: Mcp-Name header value '{raw}' does not match body value '{params.get('name')}'"
187+
return None
188+
189+
190+
def _compact_json(value):
191+
if isinstance(value, str):
192+
return value
193+
return json.dumps(value, separators=(",", ":"), ensure_ascii=False, default=str)

0 commit comments

Comments
 (0)