tmodloader-mcp
Drive a running tModLoader instance from an agent — launch it, ask it questions, photograph it, and read its state back as structured data.
How it works · Quick start · Configuration · Known limits
Game engines have grown MCP servers — Unity, Unreal, Godot and Defold all have one, so an assistant can see a real scene instead of guessing from a prompt. tModLoader has not had one. This is that.
The tool staged its own screenshot —
time:noon, teleport:spawn, spawn:1,25,
shot:full — read from the game's back buffer, so a window in
front of the game cannot be in the picture
(How it works starts there). Trimmed of the
unexplored dark below the terrain; otherwise exactly as returned.
Status: alpha — it has only ever run on one install; see Known limits before adopting it. Nothing defaults to anybody's install, the mod-side half is a folder you vendor (
responder/), and CI compiles it with nothing of any mod's on the compile line.
Quick start
uv tool install tmodloader-mcp # or: pip install tmodloader-mcp
# the two paths with no default — every plausible default names somebody's install
export TMODLOADER_SAVE_DIR="/mnt/c/Users/<you>/Documents/My Games/Terraria/tModLoader"
export TMODLOADER_MOD_SOURCE="$TMODLOADER_SAVE_DIR/ModSources/<YourMod>"
Then:
- Vendor the responder — copy
responder/into your mod's source tree and subclassDevResponder. It is a folder of C#, not a PyPI dependency, because it compiles inside your mod. - Point your MCP client at the server — the block in Using it from Claude Code.
- Ask the game something —
launch, thendiag. Or open with thestart_a_sessionprompt, which lists the worlds and characters that actually exist here.
This needs WSL2 driving a Windows tModLoader — Requirements says why before anything else does.
How it works
The game is asked by writing a file it polls, not by sending it input:
┌─────────────────────────────────────────────────────┐
│ an agent — Claude Code, or anything speaking MCP │
└──────────────────────┬──────────────────────────────┘
│ tools · prompts · capture:// resources
┌──────────────────────▼──────────────────────────────┐
│ tmodloader-mcp — this package (Python, WSL2) │
└──────────────────────┬──────────────────────────────┘
│ writes <mod>-capture.trigger
│ reads <mod>-diag-<token>.txt,
│ <mod>-shot-<token>.png, ...
┌──────────────────────▼──────────────────────────────┐
│ the tModLoader save directory │
│ plain files on disk — no socket, no keystrokes │
└──────────────────────▲──────────────────────────────┘
│ polls every few frames, writes
│ each answer next to its trigger
┌──────────────────────┴──────────────────────────────┐
│ DevResponder — C# you vendor into YOUR mod, │
│ inside tModLoader, a real game, on Windows │
└─────────────────────────────────────────────────────┘
No synthetic keystrokes, no window focus, and nothing that can be fooled by another window sitting on top of the game. That last point is the reason for the design: OS-level screen capture was tried first and returned a picture of Discord — a window in front of the game — while passing every check available. Reading the game's own back buffer cannot contain another window by construction, not by luck.
Captures name a region and have no default. The frame holds only the game, but that still includes a character name, a world name and any chat on screen, so a request says which corner it wants.
The mod side of that protocol — every filename, what each one contains, and
which failures it has to be able to express — is written down in
docs/MOD_CONTRACT.md,
and implemented in
responder/.
You can read the contract or vendor the folder; the folder is the same document
with a compiler checking it.
Since 0.6.0 replies are tagged: a request may carry a short id the responder echoes back first, so a late answer can never be mistaken for the next request's. Older vendored copies keep working — the tag is only sent to a responder that advertises taking it.
What the mod side answers
These are verbs, driven through the trigger tool, not separate MCP tools.
The reads come with the base class — every consumer serves them, and vendoring an upgrade can never hand your mod a power it did not have before:
| Verb | What it answers |
|---|---|
capture / shot |
a photograph of the frame, by named region |
diag |
whatever your mod chose to report |
tiles |
tile-type counts in a rectangle |
entities |
NPC, item or projectile counts, filterable by rectangle |
find |
one line per entity: id, position, health |
players |
who is connected |
The writes are opt-ins, each one line you write in RegisterCommands:
| Opt-in | Verbs it adds |
|---|---|
DevMutations |
time weather spawn give teleport settile cleartile despawn |
DevCommandBridge |
command commandlist — runs any mod's own registered ModCommands |
DevChat |
chat say |
One line each is deliberately the whole mechanism: not a setting, not a marker
file, not an environment variable, because each of those can be switched on
somewhere other
than the source somebody will read when they ask why an NPC appeared in their
world. responder/README.md
has the detail, including why DevCommandBridge is the answer to "what about
an escape hatch" and why there is no reflect_invoke here.
The answers, verbatim
Every line below is real — the same server and client the live checks drive, quoted rather than paraphrased:
spawn:1,20 OK: spawned 20 of 20 id=1 at tile 2101,252
time:dusk sent to a CLIENT:
REFUSED: "time" changes something the SERVER owns, and a
client that changed it would be corrected by the next world
packet - the change would appear to work and then undo
itself. Send this to the server: time@<server-address>.
give:8,5 sent to the SERVER:
REFUSED: "give" needs a local player, and a dedicated
server has none - it runs the world without standing in
it. Ask a client, by name.
spawn:0,1 REFUSED: 0 is not a NPC id - it is how Terraria spells
"nothing", so this would have succeeded and done nothing
spawn:banana,5 REFUSED: "banana" is not a positive whole NPC id
time:teatime REFUSED: "teatime" is not one of dawn, noon, dusk, midnight
tiles:0,0,1000,1000
REFUSED: 1000 by 1000 is 1000000 tiles, past the limit of
16384 one query may scan
The refusals are the point. Each one says what was wrong, what would have been right, and — when the verb belongs to the other side of the wire — where to send it instead, because the reader is an agent and an agent retries exactly as well as the refusal explains.
The tool surface
Most of what this does could be a CLI, and where that is true it should stay one — a stateless local binary does not need a protocol in front of it. What earns the surface here is that a running game is not stateless:
A session
| Tool | What it buys over bash |
|---|---|
launch / stop |
Session state across calls |
join |
A second client into a session that is already running |
status |
Asking whether a session exists without provoking an error |
restart |
stop → build → launch in the one order that works |
inventory |
The worlds, characters and mods launch needs and cannot check |
Asking the game
| Tool | What it buys over bash |
|---|---|
trigger |
The write → poll → timeout → clean-up loop, written once |
commands |
What the mod says it serves, read from the mod, not a copy here |
diag |
Structured fields AND the records under them, not text to sed |
wait_until |
Waiting for a state on one budget, instead of sleeping a guess |
heartbeat |
WHICH silence — absent, stale, still loading, or not armed |
Pictures
| Tool | What it buys over bash |
|---|---|
shot |
A path per call, a whole PNG behind it, refusals as refusals |
captures |
Which captures exist, as names — a reader that takes no paths |
read_capture |
The picture itself, for an agent not on this machine |
prune_captures |
Removing captures without a delete loose enough to reach a world |
Logs
| Tool | What it buys over bash |
|---|---|
logs |
Any log, filtered — including the run that already rotated away |
log_files |
Which logs exist right now, and how many old runs are archived |
log_since |
Only what a log gained, and whether it rotated under you |
log_watch |
Blocking until a line appears, instead of a guessed sleep |
Building, the API, and the saves
| Tool | What it buys over bash |
|---|---|
build_mod |
Encodes tModLoader's refusal to build while the game is open |
api_search |
What the INSTALLED tModLoader actually exposes, with signatures |
save_snapshot |
Copying the world and characters aside before a run mutates them |
save_restore |
Putting them back, saving what it overwrote so it can be undone |
save_snapshots |
Which copies exist, newest first |
Those one-liners are where the hand-written version actually went wrong: a
pkill pattern that matched its own command line, a readiness check that
passed on a killed process's leftover heartbeat, and — for one stretch — a
shot row promising a PNG check that did not exist: the file was waited for
and renamed, never opened. It exists now, and reads the end that decides — a
truncated PNG has a perfectly valid signature, so the trailer is what is
checked. The gap was recorded here rather than quietly corrected, because a
README is read by people deciding what they no longer have to check.
Two prompts ship with it: diagnose_silence walks the four reasons the
mod might not answer, with this install's heartbeat, mod list and logs already
read; start_a_session lists the worlds and characters that actually exist
here — the two preconditions launch states and cannot check. Both render the
failure into the text when the configuration is unusable, because a diagnostic
that refuses to render has failed at the one moment it was for.
Captures are also addressable as capture://{name} resources. Both surfaces
share one reader that takes a name, never a path, and serves only
capture-shaped files inside the save directory — a reader that opened whatever
it was handed would be the leak this project exists to prevent.
What it cannot do
There is no headless singleplayer. Terraria has no entry point for it —
-join -player -skipselect lands at the main menu, measured rather than
assumed. launch("singleplayer") refuses and says so instead of launching
something else and letting you believe otherwise. Singleplayer testing needs a
human to load a world; the other tools then drive it normally.
That matters more than it sounds: a bug that only appeared in singleplayer shipped once precisely because every harness ran server-plus-client.
There is no bare dedicated server either. An empty server runs no update
hooks, so the mod never polls and never answers — measured on one process,
changing only whether a client was attached: silent for 90s alone, answering
within 30s of a client joining. launch("server") refuses for the same reason
singleplayer does — what it promises is a game that can answer, and a server
on its own never becomes one. Start one outside this tool if you want a server
to join yourself.
Requirements
WSL2 on Windows, driving a Windows tModLoader. This is the one requirement
worth reading before the others, because it is not a preference — sessions are
listed and killed through Windows' own tasklist.exe and taskkill.exe, and
build_mod hands tModLoader a Windows path because it builds inside a Windows
process. A native Linux or macOS tModLoader cannot be driven by this as it
stands. The configuration check every tool runs first says so by name rather
than failing later on a missing file in System32; if you are on WSL and those
tools live somewhere unusual, set
TMODLOADER_TASKLIST, TMODLOADER_TASKKILL and TMODLOADER_POWERSHELL.
- Python 3.12+
- tModLoader installed (1.4.4.9 is what this is tested against)
- A mod embedding the trigger-file responder — copy
responder/into your mod's source tree and subclassDevResponder - A .NET SDK, for
api_searchonly — the index is built by a small C# tool. Everything else works without one.
Install
uv tool install tmodloader-mcp # or: pip install tmodloader-mcp
# or none at all: uvx tmodloader-mcp
The package is the Python half only. The responder is not on PyPI and could
not usefully be — it is C# that compiles inside your mod — so it is
vendored from
responder/
in the repository, however you obtained the package.
Configuration
Every path is an environment variable. Two are required, because every plausible default for them names somebody's own install:
| Variable | Meaning |
|---|---|
TMODLOADER_SAVE_DIR |
Required. Where the mod writes artifacts |
TMODLOADER_MOD_SOURCE |
Required. Mod source directory (WSL path) |
TMODLOADER_DIR |
tModLoader install; defaults to Steam's layout |
TMODLOADER_WORLD_WIN |
Default world, as Windows spells it — see below |
TMODLOADER_MOD_SOURCE_WIN |
Usually leave unset — see below |
TMODLOADER_MOD_NAME |
Usually leave unset — see below |
export TMODLOADER_SAVE_DIR="/mnt/c/Users/<you>/Documents/My Games/Terraria/tModLoader"
export TMODLOADER_MOD_SOURCE="$TMODLOADER_SAVE_DIR/ModSources/<YourMod>"
The required two have no default on purpose: a default pointing at the author's disk does not fail on yours — it resolves, worst case to something that exists, and the server drives an install you never chose. Both unset variables are reported together, so this costs one restart, not two.
TMODLOADER_WORLD_WIN is the world launch loads when you do not pass one. It
has no default either; with neither set, launch refuses and lists the worlds
actually in your save directory, with the Windows paths it wants. inventory
answers the same question without launching anything.
TMODLOADER_MOD_NAME — when the derived name is wrong,
and what the name keeps apart
TMODLOADER_MOD_NAME is the mod's internal name, which every artifact
filename is built from: <modname>-diag-<token>.txt, <modname>-shot-<token>.png,
lowercased, where <token> identifies which player's client wrote it — except
<modname>-capture.trigger and <modname>-commands.txt, which stay one name
shared by every client (see
docs/MOD_CONTRACT.md
for why).
tModLoader takes the mod's name from the source folder, so it is derived from
TMODLOADER_MOD_SOURCE and only needs setting for a checkout whose folder is
named something other than the mod. Deriving it is also what keeps two mods
driven from one machine out of each other's trigger files — they share a save
directory.
TMODLOADER_MOD_SOURCE_WIN — only for a mod source living
outside /mnt/<drive>
TMODLOADER_MOD_SOURCE_WIN is the mod source as Windows sees it, which -build
needs because tModLoader compiles inside a Windows process with no /mnt/c. It
is derived from TMODLOADER_MOD_SOURCE, so setting that one is enough for a
source on a drive mount. Set it yourself only if your mod source lives outside
/mnt/<drive>, where there is no drive letter to translate to and the server
will ask for it by name.
The two describe one directory. If you set both to different places the server refuses to start and says so, rather than driving one and building the other.
Using it from Claude Code
A .mcp.json ships with the repository, so a session started in this directory
finds the server:
{
"mcpServers": {
"tmodloader": {
"command": "uv",
"args": ["run", "tmodloader-mcp"],
"env": {
"TMODLOADER_SAVE_DIR": "${TMODLOADER_SAVE_DIR}",
"TMODLOADER_MOD_SOURCE": "${TMODLOADER_MOD_SOURCE}"
}
}
}
}
The two paths are read from your environment rather than written down — a
committed config with real paths would be one person's paths in everybody's
checkout. Export them where the client is launched, not only in an
interactive shell: the substitution is the client's, against its own
environment, and a value passed through as literal ${TMODLOADER_SAVE_DIR} is
treated as absent and reported by that name. claude mcp list names any that
are missing.
If the server reports a variable missing that you know you exported — the usual cause is a client older than the export
A process's environment is a copy taken when it starts, and nothing
outside can add to it afterwards. So a long-lived parent — a daemon, an agent
host, a desktop session — hands every client it spawns the environment it had
on the day it started, however long ago that was, and adding the variables to
your profile today does not reach it. The symptom is a shell where
env | grep TMODLOADER prints all three sitting next to a server that sees
none of them. Restarting the client is not enough if the thing that spawned the
client is the stale one; restart that.
Nothing can be repaired from inside a running session: the value was gone before the process started.
A project-scoped .mcp.json needs approving once — Claude Code will not run a
server a repository asked it to run without being told to. Start claude in
this directory and accept the prompt.
To drive the harness from the directory where you actually develop your mod,
copy the block into that project's .mcp.json and point --directory at this
checkout:
"args": ["run", "--directory", "/path/to/tmodloader-mcp", "tmodloader-mcp"]
Or, with the package from PyPI, no checkout at all:
"command": "uvx",
"args": ["tmodloader-mcp"]
The repository's own .mcp.json stays on uv run deliberately — inside this
checkout you want the code in front of you, not the release behind it.
Known limits
Everything below is a fact about this repository rather than a plan; the
struck-through history this section replaced lives in
CHANGELOG.md,
which is where a changelog belongs.
The thing most worth knowing about this project is that its hardest bugs were found by RUNNING it. Two clients overwriting each other's answers, a capture lock bounded by a guess, a dedicated server with no address to be told apart by, and — most recently — a save-snapshot feature whose entire premise turned out to be false when somebody finally measured it. The unit suite passed against every one of those. The changelog is the record.
It has only ever run on one install. One machine, one tModLoader
(1.4.4.9), one world, one character. Every live check in tests/ drives a real
game rather than a mock, which is the strongest evidence this project has — and
it is still evidence from a single configuration. That is what the alpha
classifier is for, and the first thing an outside user is likely to find is
something install-specific.
Two dedicated servers racing for one trigger is unobserved. A server is
addressed by its port and answers under that name, and each half of that
mechanism was checked with one server and a hand-written trigger. Running two
at once needs a second world;
template/
is the cheapest route to one.
There is no escape hatch, deliberately. Other harnesses ship
reflect_invoke or execute_code. command is the answer here: it runs a
mod's OWN registered ModCommands, which the mod already decided existed,
named, and gave a usage line. That keeps every reachable action published,
typed and refusable. Arbitrary evaluation would buy unlimited reach and throw
that away, so a question nobody wrote a verb for still costs an edit, a
rebuild and a relaunch.
A stopped session saves nothing. stop force-kills, so a run that changes
the world usually leaves no trace on disk — measured, not assumed. Do not rely
on that: a run long enough to autosave, or a graceful exit, does write. Take a
save_snapshot before anything that mutates a world you care about.
MIT · what running it taught, in order · the protocol, written down · a template mod to start from
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tmodloader_mcp-0.6.0.tar.gz.
File metadata
- Download URL: tmodloader_mcp-0.6.0.tar.gz
- Upload date:
- Size: 413.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
71a3a4668c0c4c892ebfc1e12fdb62d3302e72b44146eca638cf3879e8016553
|
|
| MD5 |
72cfa7a21afba79bbc8c463209300e4f
|
|
| BLAKE2b-256 |
d0fbc65057af12151217e1850776aed82f13fe507cc2ebb4d0a8c599f0e1e884
|
File details
Details for the file tmodloader_mcp-0.6.0-py3-none-any.whl.
File metadata
- Download URL: tmodloader_mcp-0.6.0-py3-none-any.whl
- Upload date:
- Size: 131.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7caa1705e314fa72a7c73184da54f4437c5d1159d5fb1734d5a1647865fabbf8
|
|
| MD5 |
3d986d14b1fff813abeb193041d85995
|
|
| BLAKE2b-256 |
3a85022f7af2d7d4c8c32bd2bab43f04735fad54e387a77be7996d91e5fd9316
|