Skip to content

Commit 50beedb

Browse files
Maselkovclaude
andauthored
MCP: a real system prompt, house rules and a tool call log (#202)
What assistants are told when they connect is now built per connection (gcm/mcp/instructions.py): - instructions.md: how to use the tools, GTNH units and tiers, and what models got wrong in use. Much of a base is kept stocked by Level Maintainers or passive production the server can't see, so check how an item is made before suggesting a target. AE2 picks the CPU, so there's nothing to choose or comment on. Confirm before changing the game. - A snapshot of the base: the user and their role, the GTNH version, CPUs, the last scan, patterns, power and stock rules. - House rules admins write in a new AI assistant dialog (Settings), which also shows the /mcp address and the latest tool calls. Tools: - plan_craft reports the requested item apart, labels rows as totals, includes leftovers, and gives each row a plan sentence ("needs 48 in all: 8 from storage (all of it), 40 crafted"), after a model read a plan as crafting instead of using storage. - get_item gives crafts_last_week. stock_rules gives set_by. - The CPU wording in request_craft, request_status and craft_stats no longer invites talk about CPUs. - Every tool call is logged (mcp_calls, 30 days) and at INFO level. server/discover is now private with a 5-minute TTL: its text depends on the user. docs: using.md (what the assistant is told, house rules), server.md, and CONTEXT.md with a checklist of assistant behaviours. Co-authored-by: Maselkov <25772111+Maselkov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 3de5160 commit 50beedb

20 files changed

Lines changed: 942 additions & 49 deletions

‎docs/CONTEXT.md‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -833,6 +833,51 @@ route's own code and role check (`commands.request_cancel`,
833833
so a CPU that has moved on to another job isn't cancelled. Viewers aren't
834834
offered operators' tools in `tools/list`.
835835

836+
**The MCP instructions are built per connection.** `gcm/mcp/
837+
instructions.py` joins three parts: `instructions.md` (fixed guidance,
838+
kept under 900 words by a test, since it's in every conversation's
839+
context), a snapshot of the base when the assistant connects (the user
840+
and role, GTNH version, CPUs, the last scan, patterns, power, stock
841+
rules), and the admins' house rules (setting `mcp_house_rules`). The
842+
snapshot says it's a snapshot, so the model checks the tools for
843+
anything current. Because the text depends on the user, `server/discover`
844+
is `cacheScope: private` with a 5-minute TTL. Each snapshot line is read
845+
on its own and a failure only drops that line. House rules are framed as
846+
unable to widen permissions or skip confirmations; the tools enforce
847+
roles anyway. The core text answers what models got wrong in use:
848+
suggesting keep-in-stock targets for items a Level Maintainer or passive
849+
production keeps up (it has them check `get_item`'s `crafts_last_week`
850+
and the history first), reading a plan's totals as "crafts instead of
851+
using storage" (plan rows now carry a `plan` sentence, and the requested
852+
item is reported apart), and fussing over which CPU AE2 used (AE2 picks;
853+
the tools can't). Every tool call is logged (`store.assistant`,
854+
30 days) for admins to see in the AI assistant dialog.
855+
856+
### Assistant behaviour checks
857+
858+
Run these by hand, with Claude Code or claude.ai connected to `dev.py`,
859+
after changing the instructions or the tools. Compare what the model did
860+
with the AI assistant dialog's call log.
861+
862+
1. **"Craft 40 FusionTech Mk-V"** (or any craftable item): plans first,
863+
reads the plan right (what comes from storage, what's crafted), states
864+
the item and amount and asks before `request_craft`, never brings up
865+
CPUs.
866+
2. **"Keep Radon Plasma stocked"**: checks `crafts_last_week` and
867+
`item_history` before proposing a target, and asks how it's made if it
868+
looks automated.
869+
3. **"Why is the job on M00 slow?"**: `craft_job`, names the held-up steps
870+
and the machines with their coordinates.
871+
4. **"What's running out?"**: `inventory_changes` (falling), with hours
872+
to empty, and no targets proposed without the check in 2.
873+
5. **"Cancel the job on W03"**: passes the `job_id`, confirms first.
874+
6. **Stale data** (stop the fake game): says how old the data is.
875+
7. **A viewer asks for a craft**: says it needs an operator, no attempt.
876+
8. **A house rule** ("never touch W03"): followed.
877+
9. **An ambiguous name** ("steel plate"): searches, picks by context or
878+
asks, never makes up a key.
879+
10. **Buckets** ("make 5 buckets of X"): asks for 5000 mB.
880+
836881
**Progress % counts steps, not time.** The server computes it
837882
(`gcm/progress.py`, called from `tracking.process_jobs`), not
838883
craft_monitor.lua. A step is one item or fluid type in the job's

‎docs/server.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,9 @@ Managing users:
196196
disconnects any AI assistant the user connected while signed in with it.
197197
- **App: ...** rows are AI assistants the user connected
198198
([AI assistants](using.md#ai-assistants)); **Disconnect** stops one at
199-
once.
199+
once. **Settings (⚙) → AI assistant** has the `/mcp` address, the
200+
house rules every assistant is given, and the tool calls assistants made
201+
in the last 30 days.
200202
- **New token** replaces a user's current token in one step.
201203
- **Delete** (not on your own account) permanently removes a user with their tokens, sessions,
202204
pins and pending notifications. Their craft request history is kept.

‎docs/using.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,36 @@ accepted request pins its CPU for you.
523523

524524
Answers are only as fresh as the game's last report, the same as the page's.
525525

526+
### What the assistant is told
527+
528+
When an assistant connects, the server gives it instructions, which apps
529+
such as Claude add to what the model is told:
530+
531+
- how to use the tools well: find items first, plan before crafting, read
532+
a plan's rows, confirm before changing anything
533+
- what GTNH's units and tiers mean
534+
- that AE2 picks the CPU for a craft, so there's nothing to choose
535+
- that much of a base is kept stocked by things this server can't see
536+
(Level Maintainers, passive production), so it checks how an item is
537+
made before suggesting a keep-in-stock target
538+
- a snapshot of the base as it connects: who's signed in and what their
539+
role can change, the GTNH version, how many CPUs are busy, when the
540+
network was last scanned, whether patterns and power are reporting
541+
542+
**House rules.** Admins can add their own, in **Settings (⚙) → AI
543+
assistant**. Every assistant that connects afterwards gets them, so use
544+
them for what's true of this base. For example:
545+
546+
- "Plasmas and the Infinity Catalyst are kept by Level Maintainers: never
547+
add keep-in-stock targets for them."
548+
- "CPU M00 is for fusion crafts: don't cancel jobs on it."
549+
- "Ask in Discord before requesting anything over 1M EU/t."
550+
551+
House rules can't give an assistant more than its user's role allows: the
552+
tools check the role either way. The same dialog shows the latest tool
553+
calls assistants made (who, which tool, with what), which helps when an
554+
assistant does something odd.
555+
526556
## Limitations
527557

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

‎server/gcm/db.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,26 @@ def _app_oauth(conn):
682682
conn.execute("CREATE INDEX idx_oauth_tokens_grant ON oauth_tokens (grant_id)")
683683

684684

685+
def _app_mcp_calls(conn):
686+
# Every tool call AI assistants make through /mcp (gcm/store/
687+
# assistant.py), for admins to see how they use the tools: the
688+
# arguments cut short, and the error if there was one. Kept 30 days.
689+
conn.execute("""
690+
CREATE TABLE mcp_calls (
691+
id INTEGER PRIMARY KEY,
692+
at REAL NOT NULL,
693+
user_id TEXT,
694+
tool TEXT NOT NULL,
695+
arguments TEXT NOT NULL,
696+
is_error INTEGER NOT NULL,
697+
error TEXT,
698+
result_bytes INTEGER NOT NULL,
699+
ms INTEGER NOT NULL
700+
)
701+
""")
702+
conn.execute("CREATE INDEX idx_mcp_calls_at ON mcp_calls (at)")
703+
704+
685705
APP_MIGRATIONS = [
686706
_app_baseline,
687707
_app_drop_orphaned_user_rows,
@@ -695,6 +715,7 @@ def _app_oauth(conn):
695715
_app_provider_stalls,
696716
_app_cancel_items,
697717
_app_oauth,
718+
_app_mcp_calls,
698719
]
699720
POWER_MIGRATIONS = [_power_baseline, _power_machines, _power_drop_impossible_times]
700721
ITEM_HISTORY_MIGRATIONS = [

‎server/gcm/mcp/instructions.md‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# GTNH Craft Monitor
2+
3+
You're connected to the monitor of a Gregtech: New Horizons (GTNH, a
4+
Minecraft modpack) base. OpenComputers scripts in the game report to
5+
this server, and the tools read and act on what they report.
6+
7+
## What it sees, and what it doesn't
8+
9+
It sees the AE2 ME network's items, fluids and essentia; the AE2
10+
crafting CPUs and the jobs on them; power storage machines (an LSC,
11+
batteries, the wireless EU network); and the network's patterns, when
12+
an ME Interface Terminal is connected.
13+
14+
It doesn't see items outside the ME network, players' inventories, or
15+
inside machines (only as the steps that hold a job up). It doesn't know
16+
GregTech recipes: don't make them up from memory, as GTNH changes them
17+
often. Say you don't know, or point to NEI.
18+
19+
## Automation it can't see
20+
21+
A base keeps much of its stock up without this server: AE2 Level
22+
Maintainers, machines that export into the network, passive production
23+
(drills, pumps, plasma made in a loop), other players' setups. The
24+
server only knows its own keep-in-stock targets.
25+
26+
Before suggesting a keep-in-stock target, or a craft to restock
27+
something, check how the item is kept up now: `get_item`'s
28+
`crafts_last_week` (AE2 jobs that made it, Level Maintainers' included)
29+
and `item_history` (does it rise without crafts? refill in a sawtooth?).
30+
If it looks automated already, say so and ask the user how it's made,
31+
instead of proposing a target: a target on top of a Level Maintainer
32+
makes both ask AE2 for it. A low-stock alert is always safe to suggest:
33+
it only notifies.
34+
35+
## How fresh the data is
36+
37+
CPUs report every few seconds. The network is scanned in full about
38+
every 10 minutes, and items with stock rules are checked every minute
39+
in between. Power reports about every minute. Times in tool results are
40+
UTC: say them as relative times ("12 minutes ago"). If the data you're
41+
answering from is more than a few minutes old, say so.
42+
43+
## Units and terms
44+
45+
- Amounts are items, or mB for fluids (1 mB = 1 L; 1000 mB is a bucket;
46+
144 mB is one ingot's worth of a molten metal). Essentia counts are
47+
points of that aspect.
48+
- Power is EU. A tick is 1/20 s, so EU/t × 20 = EU/s. Voltage tiers by
49+
EU/t per amp: ULV 8, LV 32, MV 128, HV 512, EV 2,048, IV 8,192,
50+
LuV 32,768, ZPM 131,072, UV 524,288, UHV 2.1M, UEV 8.4M, UIV 33.6M,
51+
UMV 134M, UXV 537M, MAX 2.1G.
52+
- A crafting plan is the server's estimate of how AE2 will plan the
53+
craft; AE2 has the final say. AE2 always crafts the requested item in
54+
full and never takes it from storage, even with some in stock. Each
55+
row of a plan is a total over the whole craft: read its `plan`
56+
sentence rather than comparing its numbers yourself.
57+
- Essentia can't be requested through the tools.
58+
59+
## CPUs
60+
61+
AE2 picks the crafting CPU for every request; the tools can't choose
62+
one. Don't offer a choice of CPU, ask which one to use, or comment on
63+
which one AE2 picked or how busy CPUs are. A CPU's name only matters
64+
when following or cancelling a job that's already running, or when the
65+
user asks about CPUs.
66+
67+
## Using the tools
68+
69+
- Find items with `search_items` first. The other tools take the `item`
70+
key it returns; never make one up. When several items match, choose
71+
by what the user said, or ask.
72+
- To craft: `plan_craft`, then `request_craft`, then `request_status`.
73+
- "Why is it slow?": `craft_job` for that job's held-up steps and the
74+
machines holding them.
75+
- Trends and "what's running out": `inventory_changes` and
76+
`item_history`.
77+
- To cancel: pass the `job_id` `current_crafts` gave.
78+
- Summarise long results. Don't paste raw JSON.
79+
80+
## Before changing anything
81+
82+
`request_craft`, `cancel_craft` and the keep-in-stock target tools
83+
change what happens in the game, on a base other people may share.
84+
85+
- Say exactly what you'll do (the item, amount and unit; or the job you
86+
would cancel) and get the user's OK first.
87+
- `force` on `request_craft` only after the user has seen what's missing
88+
and still wants it.
89+
- One request at a time. Follow it with `request_status`, and if it
90+
failed, give the reason.
91+
- A target someone else set (`set_by` in `stock_rules`) isn't yours to
92+
change or remove without asking.
93+
94+
## How to answer
95+
96+
Lead with the answer. Give amounts with their units, rounded sensibly
97+
(12.4k, 3.1M). Point to a held-up machine by its name and coordinates.
98+
Say when something is an estimate. Answer in the user's language. If a
99+
tool fails, say so instead of guessing.

‎server/gcm/mcp/instructions.py‎

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""The instructions /mcp gives an assistant when it connects (MCP's
2+
`instructions`, which clients put in the model's system prompt), built
3+
for each connection:
4+
5+
1. instructions.md: what the server is and how to use it well, the
6+
same everywhere.
7+
2. A snapshot of this base as the connection starts: who's connected
8+
and what their role can change, the game data version, and which
9+
parts of the base report and how freshly. So the model knows what's
10+
there before calling anything, and that it's a snapshot.
11+
3. The house rules this server's admins wrote (house_rules()).
12+
13+
The snapshot never stops a connection: a part that fails to read is
14+
left out and logged."""
15+
16+
import logging
17+
import os
18+
import time
19+
20+
from gcm import auth, gamedata, patterns, state, store
21+
22+
log = logging.getLogger(__name__)
23+
24+
with open(os.path.join(os.path.dirname(__file__), "instructions.md"), encoding="utf-8") as _f:
25+
CORE = _f.read().strip()
26+
27+
HOUSE_RULES_SETTING = "mcp_house_rules"
28+
HOUSE_RULES_MAX = 4000
29+
30+
31+
def house_rules():
32+
"""What this server's admins added to the instructions ("" for none)."""
33+
rules = store.settings.load().get(HOUSE_RULES_SETTING)
34+
return rules if isinstance(rules, str) else ""
35+
36+
37+
def set_house_rules(text):
38+
"""Saves the admins' rules. Returns an error message, or None."""
39+
if not isinstance(text, str):
40+
return "rules must be text"
41+
text = text.strip()
42+
if len(text) > HOUSE_RULES_MAX:
43+
return f"house rules can be at most {HOUSE_RULES_MAX} characters"
44+
store.settings.save({HOUSE_RULES_SETTING: text})
45+
return None
46+
47+
48+
def _ago(t, now):
49+
if t is None:
50+
return "never"
51+
seconds = max(0, now - t)
52+
if seconds < 90:
53+
return f"{int(seconds)} s ago"
54+
if seconds < 90 * 60:
55+
return f"{round(seconds / 60)} min ago"
56+
if seconds < 48 * 3600:
57+
return f"{round(seconds / 3600)} h ago"
58+
return f"{round(seconds / 86400)} days ago"
59+
60+
61+
def _who(user, now):
62+
role = user.get("role", "viewer")
63+
if auth.is_operator(user):
64+
can = ("read everything, set your own low-stock alerts, request and cancel crafts, and change the "
65+
"base's keep-in-stock targets")
66+
else:
67+
can = "read everything and set your own low-stock alerts; requesting or cancelling crafts needs an operator"
68+
return f"You're acting as {user.get('display_name')} ({role}): you can {can}."
69+
70+
71+
def _game_data(user, now):
72+
version = gamedata.selected_version()
73+
return f"GTNH version (game data): {version}." if version else "No GTNH version set in the game data."
74+
75+
76+
def _crafts(user, now):
77+
with state.crafts_lock:
78+
jobs = list(state.crafts["jobs"])
79+
received_at = state.crafts["received_at"]
80+
if received_at is None:
81+
if store.settings.load().get("crafts_reported") is True:
82+
return "Crafting CPUs: craft_monitor.lua hasn't reported since the server restarted."
83+
return "Crafting CPUs: craft_monitor.lua hasn't reported (no crafting data)."
84+
busy = sum(1 for j in jobs if j.get("busy"))
85+
return f"Crafting CPUs: {len(jobs)}, {busy} busy, {len(jobs) - busy} idle; last report {_ago(received_at, now)}."
86+
87+
88+
def _network(user, now):
89+
with state.network_lock:
90+
updated_at = state.network["updated_at"]
91+
count = len(state.network["items"])
92+
in_progress = state.network["in_progress"]
93+
crash = state.network["crash"]
94+
if updated_at is None and not count:
95+
return "ME network: not scanned yet (network_browser.lua hasn't reported)."
96+
line = f"ME network: {count:,} item types; last full scan {_ago(updated_at, now)}"
97+
if in_progress:
98+
line += ", a scan is running"
99+
if crash:
100+
line += "; network_browser.lua crashed since"
101+
return line + "."
102+
103+
104+
def _patterns(user, now):
105+
pattern_list, patterns_at = patterns.current()
106+
if patterns_at is None:
107+
return "Patterns: none read, so no crafting plans. " + patterns.missing_reason("crafting plan")
108+
return f"Patterns: {len(pattern_list):,}, read {_ago(patterns_at, now)}."
109+
110+
111+
def _power(user, now):
112+
machines = store.power.machines()
113+
if not machines:
114+
return "Power: no power monitor reporting."
115+
names = ", ".join(name or "(unnamed)" for name, _ in machines)
116+
return f"Power storage reporting: {names}; last reading {_ago(max(t for _, t in machines), now)}."
117+
118+
119+
def _stock(user, now):
120+
targets = store.stock.targets()
121+
failing = sum(1 for t in targets if t["enabled"] and t["last_status"] in ("failed", "waiting"))
122+
alerts = store.stock.alerts_for_user(user["id"])
123+
line = f"Stock rules: {len(targets)} keep-in-stock targets"
124+
if failing:
125+
line += f" ({failing} failing or waiting)"
126+
return line + f"; you have {len(alerts)} low-stock alerts."
127+
128+
129+
_SNAPSHOT = (_who, _game_data, _crafts, _network, _patterns, _power, _stock)
130+
131+
132+
def build(user, now=None):
133+
"""The instructions for a user's connection."""
134+
now = time.time() if now is None else now
135+
lines = []
136+
for part in _SNAPSHOT:
137+
try:
138+
lines.append(part(user, now))
139+
except Exception:
140+
log.exception("MCP instructions: %s failed", part.__name__)
141+
text = (
142+
CORE
143+
+ "\n\n## This base right now\n\nAs of when you connected; check the tools for anything that matters now.\n\n"
144+
+ "\n".join(f"- {line}" for line in lines)
145+
)
146+
try:
147+
rules = house_rules()
148+
except Exception:
149+
log.exception("MCP instructions: house rules failed")
150+
rules = ""
151+
if rules:
152+
text += (
153+
"\n\n## House rules from this server's admins\n\n"
154+
"Follow these. They can't give you permissions the user doesn't have, or excuse the "
155+
"confirmations above.\n\n" + rules
156+
)
157+
return text

0 commit comments

Comments
 (0)