Skip to main content

a pixel-art blue slime

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.

CI PyPI Python 3.12 | 3.13 MIT

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.

A running game at noon: the player at spawn among blue slimes, forest, HUD

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:

  1. Vendor the responder — copy responder/ into your mod's source tree and subclass DevResponder. It is a folder of C#, not a PyPI dependency, because it compiles inside your mod.
  2. Point your MCP client at the server — the block in Using it from Claude Code.
  3. Ask the game somethinglaunch, then diag. Or open with the start_a_session prompt, which lists the worlds and characters that actually exist here.

This needs WSL2 driving a Windows tModLoaderRequirements 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 subclass DevResponder
  • A .NET SDK, for api_search only — 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

tmodloader_mcp-0.6.0.tar.gz (413.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

tmodloader_mcp-0.6.0-py3-none-any.whl (131.5 kB view details)

Uploaded Python 3

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

Hashes for tmodloader_mcp-0.6.0.tar.gz
Algorithm Hash digest
SHA256 71a3a4668c0c4c892ebfc1e12fdb62d3302e72b44146eca638cf3879e8016553
MD5 72cfa7a21afba79bbc8c463209300e4f
BLAKE2b-256 d0fbc65057af12151217e1850776aed82f13fe507cc2ebb4d0a8c599f0e1e884

See more details on using hashes here.

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

Hashes for tmodloader_mcp-0.6.0-py3-none-any.whl
Algorithm Hash digest
SHA256 7caa1705e314fa72a7c73184da54f4437c5d1159d5fb1734d5a1647865fabbf8
MD5 3d986d14b1fff813abeb193041d85995
BLAKE2b-256 3a85022f7af2d7d4c8c32bd2bab43f04735fad54e387a77be7996d91e5fd9316

See more details on using hashes here.

Release history Release notifications | RSS feed

0.6.1

2 files

This release

0.6.0 This release

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page