Repository navigation
Releases: musharna/tmodloader-mcp
Release list
v0.6.3
What's Changed
- guardrails: skip review job on Dependabot PRs by @musharna in #67
- guardrails: run the suite a second time with no network by @musharna in #68
- nightly: a mutmut abort is red and the issue says PARTIAL by @musharna in #69
- nightly: triage reads pre-dumped survivor diffs, posts by turn 20 (template 55961e3) by @musharna in #70
- nightly: triage posts one test-named line per gap and stops after posting (template 40ad8c4) by @musharna in #71
- release 0.6.3: MCP registry listing (server.json, mcp-name marker, publish workflow) by @musharna in #72
Full Changelog: v0.6.2...v0.6.3
v0.6.2
What's Changed
- ci: guardrails (pre-commit, PR checks, nightly mutation + fuzz) by @musharna in #51
- deps: httpx2/httpcore2 -> 2.12.0 (6 security advisories) by @musharna in #52
- guardrails: review budget 80 turns + delta re-review by @musharna in #53
- security: MD5 fingerprints marked usedforsecurity=False by @musharna in #54
- guardrails: fuzz 120-turn budget by @musharna in #55
- guardrails: resync template 1d41c11 (3 house rules + mutation without .pyc) by @musharna in #56
- guardrails: CLAUDE.md, the written bar (template 013add6) by @musharna in #57
- guardrails: mutation job that can fail (template 743622c) by @musharna in #58
- guardrails: mutmut results --all takes a value (template be69a25) by @musharna in #59
- guardrails: fuzz weekly on this repo's weekday, 40 turns (template 5ffe038) by @musharna in #65
- release: 0.6.2 — ship the mcp 2.1 refusal fix by @musharna in #66
Full Changelog: v0.6.1...v0.6.2
0.6.0 — the audit release
[0.6.0] - 2026-08-17
Everything in this release comes out of one full audit of the repository —
four review passes (core concurrency, I/O, the C# responder, security), every
finding either fixed here or documented as an accepted residual where the fix
would cost more than the failure.
Fixed — the two critical findings
- The tool surface is now serialised by an explicit lock.
server.py's
header claimed synchronous tools serialise on the event loop; under mcp 2.x
they run in worker threads beneath a concurrent dispatcher, so two calls
from one batching client ran in real parallel — consuming each other's
replies, double-launching into one save directory, clearing_session
under each other. Every tool and resource now runs under_ONE_AT_A_TIME
via@_serialized, and an ast-scan test refuses any future tool that
skips it. - The mutation area cap survives 32-bit arithmetic.
settile:0,0,65536,65536,0computed an area of 2^32 — exactly zero as an
int — sailed underMaxArea, and rewrote the entire world as dirt; every
product past 2^31 went negative and passed the same way. Both cap sites
now multiply in 64 bits, with the overflow boundary pinned in tests.
Fixed — the responder (C#)
- A throwing handler no longer crashes the game silently. Dispatch wraps
command.Handlerin a catch that Reports the exception; before, a
Terraria-level throw left the update hook with the trigger already
consumed and no reply written, so the harness timed out against a dead
game with nothing on disk saying why. settilechecksTileLoader.TileCountthe wayspawnandgive
check their ceilings — load-bearing here becausePlaceTileindexes
arrays sized to that count, so an oversized id was an
IndexOutOfRangeExceptionrather than a refusal.- The pre-arm trigger clear checks addressing before deleting. A second
client's first settled poll used to destroy a request addressed to the
client already in-world serving it. - Consumption is a
File.Move, not a delete — a delete succeeds silently
on a file somebody else just deleted, so two overlapping polls could both
consume one untargeted request and both dispatch. - An unreadable trigger is retried, not served. A transient read failure
used to fall through toParse(null)— the legacy bare-trigger capture —
consuming a request nobody read and photographing nobody's question. Three
failed polls now clear it loudly instead. SyncTilessends the clamped rectangle, chunked to 100-tile squares.
Edge fills made the server serialise out-of-world tiles through 1.4's
unchecked Tilemap indexer, and a legal 16384x1 fill exceeded packet 20's
byte-sized dimensions.DevChat's recorder is unwound on unload (via a smallModSystem, so
DevResponderstill never names an opt-in class). Left wrapped, it pinned
the old load's assembly across Build+Reload and re-wrapped per reload.FrameShotwrites the PNG aside and moves it into place, closing the
torn-drop-box case a mid-write crash left behind;Settle()throttles
its recursive PNG listing to every 30th tick;ListPlayersreports
tile Y fromCenter, agreeing withfindabout where a body stands;
settilecounts pre-existing tiles separately from placed ones.
Fixed — the harness (Python)
- Replies are correlated to requests. The reply file is per player, so a
late reply to a timed-out request was returned as the answer to the next
one — forshot, a wrong picture confidently labeled. Payloads now carry
#r-<hex>when the responder publishes# replies: tagged, replies echo
it as their first line, and a stable reply wearing another request's id is
waited past. Gated on the published capability, so older vendored
responders keep working untagged. log_watchcan no longer permanently miss its line.read_since
consumed unterminated fragments and advanced past them, splitting any line
that straddled two polls where nothing could match it whole. Only whole
lines are consumed now (with a stated exception for a single line longer
than the byte cap).- Log rotation is detected by identity, not only by shrinkage. A new run
that outgrew the old offset was read mid-stream as a quiet continuation,
silently skipping the head of the new run.log_since/log_watchcarry a
fingerprintof the log's head for this. - Re-taking a snapshot cannot destroy both copies.
takedeleted the old
snapshot before the swap; a transient/mnt/clock failing the swap then
took the staging copy down with it. The old copy is moved aside and
restored on failure. restore's undo swallow is narrow. It caught everySaveError— disk
full included — and proceeded to overwrite the live save with no backup
whileundo: nullclaimed there was nothing to save. Only the new
NothingToSnapshotis waved through.restoreremoves files the snapshot does not hold (within its scope),
so a world snapshotted before its.twldexisted no longer restores into
a mismatched pair. Reported asremoved, and covered by the undo.stoppins kills to process creation time. Windows recycles pids, so
a session client that died on its own could hand its number to the
developer's own game — whichstopthen killed. The CIM query now returns
creation stamps, recorded at adoption and checked at teardown.- A broken process query is no longer an empty one.
_tml_pidsreturned
set()when PowerShell failed, sostopaimed at nothing and reported
success, releasing a session that left a running game owned by nobody.
Every caller that acts on the answer now refuses instead; cleanup paths
that are already unwinding a worse failure stay tolerant. - Breaking a stale capture lock takes the lock it judged. Unlink-by-name
could remove the fresh lock another session claimed after breaking the
same stale one — the recovery path producing the very collision the lock
prevents. The break is a rename, verified against the judged mtime, with
a fresh lock put back viaos.link(which refuses to clobber a newer
claim). The same rename-verify shape now guards_release_trigger. - A capture claim that wins with no budget left withdraws its own
request instead of leaving an unserialised capture behind the released
lock;_break_stale_lockalso no longer crashes on its own documented
race (the secondstatis gone). _await_pngtolerates the writer's share lock — DrvFs surfaces
FileShare.Noneas EACCES mid-write, which is "still being written"
wearing an exception, not a failure.shotslugs the region in the kept filename (top-left→topleft),
so every capture it reports is onecaptures/read_capturecan see.log_sinceis bounded (512KB per call, cut at a line boundary, with
truncatedand a resume point), the API index cache is written via
staging-and-rename so a killed indexer cannot install a truncated index
that passes the validity check forever,prune_capturestolerates the
other session pruning concurrently, heartbeat reads survive the
stat-then-read race, protocol files pinencoding="utf-8"on both ends,
and${VAR}/suffixplaceholders are diagnosed as unsubstituted rather
than traveling as literal paths (C:\Cash$Modstays a real path).
Security / packaging
- The sdist no longer ships
docs/superpowers/— internal planning
notes carrying real usernames and machine paths rode the wholesaledocs
include into every built tarball. Never published (the package has not
been uploaded anywhere), so the leak was latent; the include now names
docs/MOD_CONTRACT.mdalone. Thesave_restoremanifest is also no
longer trusted with paths: a tamperedfilesentry that escapes the save
directory reads as an untrustworthy snapshot, same as an unparseable one.
Documented residuals (deliberate)
- The stamp-boundary wait assumes the WSL and Windows wall clocks share
fractional-second alignment; a miss costs one collision, the bounded
failure this mechanism already accepts. - The pid diff still adopts anything that starts tModLoader during the
launch window; the creation stamps pinstopto those processes but
cannot pin the diff to our spawns. - The rename-verify put-backs (lock break, trigger release, late-claim
withdrawal) each keep a microsecond-scale three-party window, priced in
their docstrings.
Unreleased work folded into this release from between 0.5.0 and the audit:
Fixed
- Three broken anchors in
docs/MOD_CONTRACT.md, all pointing at the same
heading and all missing one hyphen:#modcapturetrigger--the-requestwhere
GitHub generates#mod-capturetrigger--the-request. A broken anchor does not
fail loudly — the link renders, the reader clicks, and the page does not
move — so these sat there for weeks and were found only by writing a checker.
Added
tests/test_doc_links.py— every relative link and anchor in the
published documentation, checked against the real files rather than a
fixture. Includes the checker shown failing on both a bad anchor and a
missing file, because a link checker that returns an empty list
unconditionally passes every document ever written.
Documentation
- The README's "Phase 2" section is gone — 105 lines of struck-through
development diary, every item already recorded in this file. Replaced with
33 lines of Known limits, which is what a reader deciding whether to
adopt this actually needs: it has only run on one install, two dedicated
servers racing for one trigger is unobserved, there is no escape hatch by
design, and a stopped session saves nothing but must not be relied on to. - The status blockquote no longer recites release notes. It says what the
project is, and points at this file for what changed. - **The README ...
v0.3.0 — two people, one game directory
0.2.0 handed another mod the responder. This one stops two sessions using it from erasing each other.
Two developers can now point the harness at the same tModLoader save directory and work at the same time. Before, one of them silently destroyed the other's work — and would have gone on doing so, because a suite that only ever runs one client cannot see it.
Per-player artifact naming — a client's ANSWERS carry a token
Requests could already be told apart (shot@n43n), but everything a client wrote back was namespaced by side only. Two clients shared one heartbeat, one diag dump, one capture reply and one shot drop box, so B's answer overwrote A's and A read B's as its own. Each client's artifacts now carry a token derived from its player name.
Behaviour change: a client that has not loaded a character has an empty name, matches no target, and can no longer be asked anything. Use heartbeat, which reads off disk and needs no cooperation from the game.
The trigger is CLAIMED, not written
One file, and os.replace wrote into it unconditionally — so B's request flattened A's while A's game was still polling for it, and A waited out its whole timeout being told the game might not be polling.
os.link onto the trigger name has the same atomicity plus an EEXIST refusal, so the loser of a race learns it lost instead of winning by erasure. A blocked caller retries out of its own budget rather than a fresh one, and the two exhaustion cases say different things: somebody else holds the slot, here is whose and how old versus you hold it, your time ran out, the game may still answer.
Everything that used to delete the shared trigger now releases it — leaving alone exactly what the mod leaves alone — so a launch or a stop no longer takes another session's in-flight request. Both sides state the rule identically, down to reading the same bytes the same way: C#'s File.ReadAllText substitutes U+FFFD rather than throwing, so this side does too.
A save directory whose filesystem cannot support an exclusive claim is refused at startup, naming the reason, rather than falling back to the write that loses requests.
What two real clients proved that reading could not
Both requests answered, 1.2s and 1.3s, where previously one timed out at 120s. Eight concurrent OS processes contend for the slot in the suite; exactly one wins.
Several of the sharpest defects here were regressions this work introduced, found only because it was run rather than argued.
Known, and documented rather than quietly carried
Two simultaneous capture requests still collide on one filename; diag and shot can spend up to ~2x the caller's stated budget; and a server_client session's server side carries no address, so two of those remain indistinguishable on disk. All three need a mod-side change — see docs/MOD_CONTRACT.md.
Full changelog: CHANGELOG.md · v0.2.0...v0.3.0
v0.2.0 — the mod-side half is yours
0.1.0 could tell another mod what to implement. This one hands it the implementation.
responder/ — the mod-side half, extracted
The responder that answers every trigger used to live inside Biomancy, so what this project could offer another mod was a document. It is now a folder you copy into your mod's source tree and subclass: nine .cs files, one abstract class, two overrides.
using TModLoaderMcp.DevBridge;
public class MyDevResponder : DevResponder
{
protected override string CollectDiag() =>
"world: " + (Terraria.Main.worldName ?? "none") + "\n";
}It is source rather than a package because tModLoader has no dependency mechanism for compile-time C#. See responder/README.md.
What the compile line proves that a source scan cannot: responder/tests/ compiles the folder with nothing of any mod's on the compile line, on a CI runner with no tModLoader installed, on every push. Vendorability is checked by the compiler rather than asserted by a reviewer.
Proven against a running game, not just at the compile line: 12 commands published on both the client and the dedicated server with the base three leading in order, diag answered on both sides, a real PNG captured and read back.
Biomancy now runs on the vendored copy — a consumer of the folder rather than its owner, which is what makes the claim checkable. Its DevCapture went from 1442 lines to 678.
Also in this release
shotverifies whole PNGs at both ends (#38). It used to wait for a file, rename it, and hand back the path without reading a byte — so anything landing on that name was promoted into your captures, and you found out one round trip later where the error names your image reader.- A
${VAR}that never expanded is treated as absent (#37), so the refusal names the variable that actually failed instead of four derived ones. responder/anddocs/now ship in the sdist.
Not done
Per-player artifact naming. Two developers on one machine share a save directory, so they share every artifact filename. Designed, deliberately left to its own change.
Full changelog: v0.1.0...v0.2.0
v0.1.0
First tagged release of tmodloader-mcp — an MCP server that drives a real, running tModLoader instance.
17 tools, 2 prompts, 1 resource.
What is verified, and how
Everything reachable without a running game is driven over the protocol in CI on Python 3.12 and 3.13 — not by calling the tool functions. That distinction is the whole thesis of this repo: a Python call returns a dict nobody validates, while over MCP the same dict is checked against a schema generated from the return annotation. It is how status stayed broken through 197 green tests.
The 8 tools that need tModLoader actually running — build_mod, launch, trigger, commands, diag, shot, restart, stop — are covered by tests/live_protocol_check.py, run by hand on a machine with the game.
The live check has now been run against this code, and it passes
At tagging time it had not been re-run since restart landed. It has now been run end to end: 17 tools advertised, a real build, a launch, commands read from the mod rather than from a copy of them, diag parsed before and after the world changed, two distinct captures read back through the protocol, restart keeping the session's world across a relaunch with the mod answering afterwards, and zero stray processes on exit.
It found no defect in this package. It found two in the script that checks it — a server subprocess spawned without an environment, and a stale key count — both fixed in #36 after the tag. The live check earned its keep by failing.
Configuration
TMODLOADER_SAVE_DIR and TMODLOADER_MOD_SOURCE are required and have no defaults. Every plausible default named somebody's own install — and the failure mode is not that a wrong default is loud, it is that it resolves.
Still not yours
The mod-side responder lives inside Biomancy rather than in a package your mod can vendor, so trigger validates against Biomancy's command set. docs/MOD_CONTRACT.md specifies the whole protocol, which is the precursor to extracting it — and records why two clients on one side do not work yet (artifacts are namespaced by side, not by player).