Skip to content

Releases: musharna/tmodloader-mcp

v0.6.3

Choose a tag to compare

@musharna musharna released this 17 Sep 08:11
3097490

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

Choose a tag to compare

@musharna musharna released this 16 Sep 17:15
bf88cb7

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

Choose a tag to compare

@musharna musharna released this 19 Aug 01:18

[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,0 computed an area of 2^32 — exactly zero as an
    int — sailed under MaxArea, 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.Handler in 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.
  • settile checks TileLoader.TileCount the way spawn and give
    check their ceilings — load-bearing here because PlaceTile indexes
    arrays sized to that count, so an oversized id was an
    IndexOutOfRangeException rather 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 to Parse(null) — the legacy bare-trigger capture —
    consuming a request nobody read and photographing nobody's question. Three
    failed polls now clear it loudly instead.
  • SyncTiles sends 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 small ModSystem, so
    DevResponder still never names an opt-in class). Left wrapped, it pinned
    the old load's assembly across Build+Reload and re-wrapped per reload.
  • FrameShot writes 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; ListPlayers reports
    tile Y from Center
    , agreeing with find about where a body stands;
    settile counts 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 — for shot, 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_watch can 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_watch carry a
    fingerprint of the log's head for this.
  • Re-taking a snapshot cannot destroy both copies. take deleted the old
    snapshot before the swap; a transient /mnt/c lock 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 every SaveError — disk
    full included — and proceeded to overwrite the live save with no backup
    while undo: null claimed there was nothing to save. Only the new
    NothingToSnapshot is waved through.
  • restore removes files the snapshot does not hold (within its scope),
    so a world snapshotted before its .twld existed no longer restores into
    a mismatched pair. Reported as removed, and covered by the undo.
  • stop pins 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 — which stop then 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_pids returned
    set() when PowerShell failed, so stop aimed 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 via os.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_lock also no longer crashes on its own documented
    race (the second stat is gone).
  • _await_png tolerates the writer's share lock — DrvFs surfaces
    FileShare.None as EACCES mid-write, which is "still being written"
    wearing an exception, not a failure.
  • shot slugs the region in the kept filename (top-left → topleft),
    so every capture it reports is one captures/read_capture can see.
  • log_since is bounded (512KB per call, cut at a line boundary, with
    truncated and 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_captures tolerates the
    other session pruning concurrently
    , heartbeat reads survive the
    stat-then-read race, protocol files pin encoding="utf-8" on both ends,
    and ${VAR}/suffix placeholders are diagnosed as unsubstituted rather
    than traveling as literal paths (C:\Cash$Mod stays a real path).

Security / packaging

  • The sdist no longer ships docs/superpowers/ — internal planning
    notes carrying real usernames and machine paths rode the wholesale docs
    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.md alone. The save_restore manifest is also no
    longer trusted with paths: a tampered files entry 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 pin stop to 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-request where
    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 ...
Read more

v0.3.0 — two people, one game directory

Choose a tag to compare

@musharna musharna released this 12 Aug 03:26

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

Choose a tag to compare

@musharna musharna released this 10 Aug 23:36

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

  • shot verifies 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/ and docs/ 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

Choose a tag to compare

@musharna musharna released this 10 Aug 03:05

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).