关于 tmodloader-mcp
tmodloader-mcp 是 Python MCP 服务,通过嵌入模组的 responder 控制并检查运行中的 tModLoader 游戏。它支持会话管理、结构化游戏状态查询和从游戏自身缓冲区截图;需由 WSL2 驱动 Windows 版 tModLoader。
主要功能
- 01启动和管理游戏会话
- 02查询实体、图块、玩家和诊断信息
- 03从游戏缓冲区捕获截图
- 04读取日志并等待游戏状态
- 05构建模组并检查已安装 API
- 06备份和恢复存档
适用场景
- 01检查运行中的模组游戏
- 02在不受前台窗口干扰时捕获游戏画面
- 03测试模组行为并恢复存档快照
- 04诊断游戏与模组通信无响应
MCP 相关
为 Agent 提供操作运行中 tModLoader 实例的 MCP 工具、提示词和 capture:// 资源。游戏通信通过嵌入模组的 trigger 文件 responder 实现。
配置
本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。
工具
24收录的工具列表 · 更新于 2 周前
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| timeout | number | 否 |
详细介绍
tmodloader-mcp
通过 Agent 操控正在运行的 tModLoader 实例 — 启动游戏、向它提问、为它拍照,并以结构化数据读取其状态。
游戏引擎已陆续有了 MCP 服务 — Unity、Unreal、Godot 和 Defold 都有一个,因此助手可以看到真实场景,而不是根据提示词猜测。tModLoader 之前还没有。这就是那个 MCP 服务。
该工具为自己的截图设置了场景 —
time:noon、teleport:spawn、spawn:1,25、
shot:full — 从游戏的后缓冲区读取,因此游戏窗口前方的窗口不会出现在图片中
(工作原理从这里开始)。裁掉了地形下方未探索的黑暗区域;除此之外,图片与返回结果完全一致。
状态:alpha — 它只在一个安装环境中运行过;采用前请参阅 已知限制。所有内容都不会默认指向任何人的安装目录,Mod 端的部分是一个需要自行纳入的文件夹 (
responder/), CI 会在编译命令行中不包含任何 Mod 的情况下对其进行编译。
快速开始
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>"
然后:
- 纳入 responder — 将
responder/复制到你的 Mod 源代码树中,并继承DevResponder。它是一个 C# 文件夹,而不是 PyPI 依赖,因为它会在你的 Mod 中编译。 - 将 MCP 客户端指向服务 — 参阅 在 Claude Code 中使用中的配置块。
- 向游戏提问 — 先运行
launch,再运行diag。或者使用start_a_session提示词打开,它会列出此处实际存在的世界和角色。
这需要通过 WSL2 操控 Windows 上的 tModLoader — 要求会先说明原因。
工作原理
通过写入游戏会轮询的文件来向游戏发送请求,而不是直接向它输入内容:
┌─────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────┘
不会模拟按键,不需要让游戏窗口获得焦点,也不会被覆盖在游戏上方的其他窗口干扰。最后这一点正是这种设计的原因:最初尝试使用操作系统级屏幕捕获,结果拍到的是 Discord——一个位于游戏前方的窗口——而且通过了所有可用的检查。读取游戏自身的后备缓冲区,从原理上就不可能包含其他窗口,而不是靠运气。
捕获请求指定一个区域,没有默认区域。帧中只有游戏画面,但仍可能包含角色名称、世界名称以及屏幕上的聊天内容,因此请求需要说明要捕获哪个角落。
该协议在模组端的内容——每个文件的名称、各自包含的内容,以及必须能够表达哪些失败情况——都记录在
docs/MOD_CONTRACT.md
中,并在
responder/
中实现。
你可以阅读这份契约,也可以直接引入该文件夹;文件夹内容与文档相同,只是还会由编译器检查。
从 0.6.0 起,回复会带有标签:请求可以携带一个短 ID,响应器会先将其回传,因此迟到的回复绝不会被误认为是下一个请求的回复。较旧的引入版本仍可正常工作——只有在响应器声明支持时,才会发送标签。
模组端会响应什么
这些是通过 trigger 工具驱动的动词,而不是独立的 MCP 工具。
读取操作由基类提供——每个使用者都能提供这些操作,引入更新永远不会让你的模组获得它原本没有的权限:
| 动词 | 响应内容 |
|---|---|
capture / shot | 按指定区域捕获帧画面 |
diag | 你的模组选择报告的内容 |
tiles | 矩形区域内的图块类型计数 |
entities | NPC、物品或投射物计数,可按矩形区域筛选 |
find | 每个实体占一行:ID、位置、生命值 |
players | 当前连接的玩家 |
写入操作均为可选启用项,每项只需在 RegisterCommands 中写一行:
| 可选启用项 | 添加的动词 |
|---|---|
DevMutations | time weather spawn give teleport settile cleartile despawn |
DevCommandBridge | command commandlist — 运行任意模组自行注册的 ModCommand |
DevChat | chat say |
机制刻意设计得只有一行:不是设置项、标记文件或环境变量,因为这些方式都可以在其他地方启用;而当有人想知道为何 NPC 出现在自己的世界里时,他们会去查看源代码。
responder/README.md
中有详细说明,包括为什么 DevCommandBridge 能回答“有没有应急通道”这一问题,以及为什么这里没有 reflect_invoke。
原样呈现的回复
下面每一行都是真实内容——与实时检查所驱动的服务端和客户端相同,这里引用原文而不做转述:
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
拒绝才是重点。每条拒绝都会说明哪里有问题、怎样才是正确的;如果该动词属于通信链路的另一端,还会说明应该把请求发到哪里,因为阅读者是 Agent,而 Agent 重试的表现取决于拒绝说明得有多清楚。
工具接口
这里的大部分功能_可以_做成 CLI;如果确实如此,就应该继续作为 CLI——无状态的本地二进制文件无需在它前面再加一层协议。之所以需要这里这一层,是因为正在运行的游戏并非无状态:
会话
| 工具 | 相比 bash 的优势 |
|---|---|
launch / stop | 在多次调用之间保留会话状态 |
join | 让第二个客户端加入已在运行的会话 |
status | 查询会话是否存在,而不触发错误 |
restart | 按唯一可行的顺序执行 stop → build → launch |
inventory | 列出 launch 所需且无法自行检查的世界、角色和模组 |
向游戏发出请求
| 工具 | 相比 bash 的优势 |
|---|---|
trigger | 将写入 → 轮询 → 超时 → 清理的循环一次编写好 |
commands | 从模组中读取它声明提供的内容,而非在此处复制一份 |
diag | 提供结构化字段及其下的记录,而不是要交给 sed 处理的文本 |
wait_until | 在一个时间预算内等待某种状态,而不是凭猜测休眠 |
heartbeat | 明确是哪种沉默——缺失、过期、仍在加载,还是尚未启动 |
图片
| 工具 | 相比 bash 的优势 |
|---|---|
shot | 每次调用对应一个路径,背后是完整的 PNG,并将拒绝作为拒绝处理 |
captures | 以名称列出现有捕获项——无需传入路径的读取器 |
read_capture | 为不在这台机器上的 Agent 提供图片本身 |
prune_captures | 清理捕获项,不必使用可能误删世界的宽泛删除操作 |
日志
| 工具 | 相比 bash 的优势 |
|---|---|
logs | 经过筛选的任意日志——包括已经轮转掉的那次运行 |
log_files | 列出当前存在的日志,以及已归档的旧运行次数 |
log_since | 仅显示日志新增的内容,以及日志是否在此期间轮转 |
log_watch | 阻塞等待某行出现,而不是凭猜测休眠 |
构建、API 和存档
| 工具 | 相比 bash 的优势 |
|---|---|
build_mod | 将 tModLoader 在游戏打开时拒绝构建的规则编码其中 |
api_search | 展示已安装的 tModLoader 实际提供的内容及其签名 |
save_snapshot | 在一次运行修改世界和角色之前,将它们复制到一旁 |
save_restore | 将它们还原,同时保存被覆盖的内容,以便撤销 |
save_snapshots | 列出现有副本,最新的排在最前 |
手写实现正是在这些单行命令上出了问题:pkill 模式匹配到了自己的命令行;就绪检查误将已被杀死进程遗留的心跳判定为通过;还有一段时间,shot 条目声称会检查 PNG,但实际上并没有——它只等待文件出现并将其重命名,从未打开过。现在已经补上了检查,会读取决定结果的末尾部分——截断的 PNG 也有完全有效的签名,因此要检查的是尾部。这里记录了这个疏漏,而不是悄悄修正,因为 README 是供人们阅读的,帮助他们判断哪些事情今后不必再检查。
随附两个提示词:diagnose_silence 会逐一介绍模组可能不响应的四个原因,其中已读取本次安装的心跳、模组列表和日志;start_a_session 会列出此处实际存在的世界和角色——这是 launch 所述的两个前置条件,且它无法检查。配置不可用时,两者都会在文本中呈现故障,因为在最需要它的时刻拒绝呈现的诊断就失去了作用。
捕获内容也可通过 capture://{name} 资源访问。两个入口共用同一个读取器,它只接受名称,绝不接受路径,并且只会提供存档目录中符合捕获内容格式的文件——如果读取器打开传入的任意内容,就会造成这个项目旨在防止的泄漏。
它无法做到什么
没有无头单人游戏模式。 Terraria 没有相应的入口——实测发现,-join -player -skipselect 会进入主菜单,而不是凭空假设如此。launch("singleplayer") 会拒绝执行并说明原因,而不是启动其他模式,让你误以为启动成功。单人游戏测试需要由真人加载世界;之后其他工具便可正常驱动游戏。
这比听起来更重要:曾有一个只在单人游戏中出现的 Bug 被发布,恰恰是因为所有测试框架都运行了服务端加客户端。
也没有纯粹的专用服务端。 空服务器不会运行更新钩子,因此模组不会轮询,也不会响应——在单个进程中进行实测,只改变是否连接客户端:单独运行 90s 毫无响应,客户端加入后 30s 内便会响应。launch("server") 会出于与单人游戏相同的原因拒绝执行——它承诺的是一个能够响应的游戏,而服务器单独运行永远无法做到这一点。如果你想自行加入服务器,请在此工具之外启动它。
要求
在 Windows 上使用 WSL2,驱动 Windows 版 tModLoader。 这是最值得先了解的一项要求,因为它并非偏好问题——会话列表和终止操作要通过 Windows 自带的 tasklist.exe 和 taskkill.exe 完成,而 build_mod 会向 tModLoader 提供 Windows 路径,因为构建是在 Windows 进程中进行的。就目前而言,原生 Linux 或 macOS 版 tModLoader 无法由此工具驱动。每个工具首先运行的配置检查会直接指出这一点,而不会等到因找不到 System32 中的文件才失败;如果你使用 WSL,且这些工具位于特殊位置,请设置 TMODLOADER_TASKLIST、TMODLOADER_TASKKILL 和 TMODLOADER_POWERSHELL。
- Python 3.12+
- 已安装 tModLoader(本项目针对 1.4.4.9 进行了测试)
- 一个内嵌触发文件响应器的模组——将
responder/复制到模组的源代码目录,并继承DevResponder - 仅供
api_search使用的 .NET SDK——索引由一个小型 C# 工具构建。其他所有功能均无需它。
安装
uv tool install tmodloader-mcp # or: pip install tmodloader-mcp
# or none at all: uvx tmodloader-mcp
此软件包仅包含 Python 部分。响应器不是 PyPI 上的软件包,也不适合发布到 PyPI——它是要在你的模组中编译的 C# 代码——因此无论你通过何种方式获取此软件包,都需要从仓库中的 responder/ 复制它。
配置
每个路径都是一个环境变量。其中两个是必填项,因为它们任何看似合理的默认值都会指向某个人自己的安装目录:
| 变量 | 含义 |
|---|---|
TMODLOADER_SAVE_DIR | 必填。 模组写入构建产物的位置 |
TMODLOADER_MOD_SOURCE | 必填。 模组源目录(WSL 路径) |
TMODLOADER_DIR | tModLoader 安装目录;默认为 Steam 的布局 |
TMODLOADER_WORLD_WIN | 默认世界,按 Windows 的拼写方式指定——见下文 |
TMODLOADER_MOD_SOURCE_WIN | 通常留空——见下文 |
TMODLOADER_MOD_NAME | 通常留空——见下文 |
export TMODLOADER_SAVE_DIR="/mnt/c/Users/<you>/Documents/My Games/Terraria/tModLoader"
export TMODLOADER_MOD_SOURCE="$TMODLOADER_SAVE_DIR/ModSources/<YourMod>"
这两个必填项故意不设默认值:指向作者磁盘的默认路径在你的机器上不会报错——最糟的情况是它指向某个确实存在的位置,导致服务运行你从未选择过的安装。两个未设置的变量会一起报告,因此只需重启一次,而不是两次。
TMODLOADER_WORLD_WIN 是未指定世界时由 launch 加载的世界。它也没有默认值;如果两者都未设置,launch 会拒绝运行,并列出你的存档目录中实际存在的世界,同时显示所需的 Windows 路径。inventory 无需启动任何内容,也能回答同样的问题。
TMODLOADER_MOD_NAME — 派生名称不正确时的处理方式,以及名称如何避免冲突
TMODLOADER_MOD_NAME 是模组的内部名称,所有构建产物的文件名都由它生成:<modname>-diag-<token>.txt、<modname>-shot-<token>.png,并转换为小写;其中 <token> 用于标识写入文件的玩家客户端——但 <modname>-capture.trigger 和 <modname>-commands.txt 除外,它们由所有客户端共用同一个名称(原因见
docs/MOD_CONTRACT.md)。
tModLoader 使用源文件夹名称作为模组名称,因此该名称会根据 TMODLOADER_MOD_SOURCE 派生;只有检出目录的名称与模组名称不同时,才需要设置此变量。派生名称也能避免同一台机器驱动的两个模组互相读取对方的触发文件——它们共用一个存档目录。
TMODLOADER_MOD_SOURCE_WIN — 仅适用于模组源文件位于 /mnt/<drive> 之外的情况
TMODLOADER_MOD_SOURCE_WIN 是 Windows 所识别的模组源路径。由于 tModLoader 在无法访问 /mnt/c 的 Windows 进程中编译,-build 需要此路径。它会根据 TMODLOADER_MOD_SOURCE 派生,因此源文件位于驱动器挂载目录时,只需设置该变量即可。只有当模组源文件位于 /mnt/<drive> 之外、没有可转换的驱动器号时,才需要自行设置;此时服务会提示你设置该变量。
这两个变量描述的是同一个目录。如果将它们设置为不同的位置,服务会拒绝启动并说明原因,而不是从一个位置运行、从另一个位置构建。
在 Claude Code 中使用
仓库中附带了 .mcp.json,因此在此目录中启动的会话可以找到该服务:
{
"mcpServers": {
"tmodloader": {
"command": "uv",
"args": ["run", "tmodloader-mcp"],
"env": {
"TMODLOADER_SAVE_DIR": "${TMODLOADER_SAVE_DIR}",
"TMODLOADER_MOD_SOURCE": "${TMODLOADER_MOD_SOURCE}"
}
}
}
}
这两个路径从环境变量中读取,而不是写死——如果提交包含实际路径的配置,其他人检出仓库时就会看到某个人的路径。请在启动客户端的地方导出这些变量,而不只是交互式 shell 中:替换由_客户端_使用其自身的环境变量完成,若传入的值是字面量 ${TMODLOADER_SAVE_DIR},则会被视为缺失,并按该名称报告。claude mcp list 会列出缺失的变量。
如果服务报告某个变量缺失,而你确定自己已导出该变量——通常是客户端比导出变量的进程启动得更早
进程启动时会获取一份环境变量副本,之后外部无法再向其中添加变量。因此,长期运行的父进程——守护进程、Agent 主机或桌面会话——会把它启动时的环境变量传递给其生成的每个客户端,无论已经过去多久;即使你今天将变量添加到配置文件中,也无法传递给它。症状是:在 shell 中运行 env | grep TMODLOADER 会看到三个变量,但旁边的服务却一个也看不到。如果启动客户端的进程已过期,仅重启客户端并不够;请重启那个进程。
运行中的会话无法修复此问题:进程启动前,变量就已经缺失了。
项目级 .mcp.json 需要批准一次——Claude Code 不会在未获允许的情况下运行仓库要求它运行的服务。在此目录中启动 claude 并接受提示。
若要从实际开发 mod 的目录驱动测试框架,请将此代码块复制到该项目的 .mcp.json,并将 --directory 指向此仓库的检出目录:
"args": ["run", "--directory", "/path/to/tmodloader-mcp", "tmodloader-mcp"]
或者,使用 PyPI 上的包,无需检出仓库:
"command": "uvx",
"args": ["tmodloader-mcp"]
仓库自身的 .mcp.json 特意保留为 uv run——在此仓库中,你需要运行眼前的代码,而不是已经发布的版本。
已知限制
以下内容都是关于此仓库的事实,而非计划;本节取代的带删除线的历史记录保存在
CHANGELOG.md,
变更日志就该放在那里。
这个项目最值得了解的一点是,最棘手的 Bug 都是在实际运行时发现的。 两个客户端互相覆盖对方的答案、由猜测设定上限的捕获锁、没有地址可供区分的专用服务器,以及——最近发现的——一个保存快照功能,其整个前提在终于有人实测后被证明是错误的。单元测试套件面对上述每一种情况都通过了。变更日志记录了这些经历。
它至今只在一套环境中运行过。 一台机器、一个 tModLoader(1.4.4.9)、一个世界、一个角色。tests/ 中的每项实时检查都驱动真实游戏,而非模拟环境,这是本项目最有力的证据——但它仍然只是来自单一配置的证据。Alpha 分类器正是为此而设,而外部用户最可能遇到的第一个问题,就是某项设置仅适用于特定安装。
曾观察到两个专用服务器争抢一个触发文件,但只发生过一次,尚未得到证实。 两台服务器共用一个存档目录,使用不同端口、运行不同世界,同时轮询同一个触发文件,处理了六个交替发出的定向请求;每个请求都由其指定的服务器应答,从未被另一台服务器应答,并且始终都被消费(tests/live_race_check.py)。一台机器、一次运行:这证明了声明协议在真实竞争中有效,但不能证明它永远有效。
这里有意不提供后门。 其他测试框架会提供 reflect_invoke 或 execute_code。这里的答案是 command:它运行模组自身注册的 ModCommand,这些命令由模组自行决定是否存在、如何命名,并为其提供用法说明。这样一来,所有可调用的操作都会公开、具有类型,并且可以被拒绝。任意求值虽能带来无限的调用范围,却会舍弃这些保障;因此,如果有人想执行的操作还没有对应动词,仍然需要修改代码、重新构建并重新启动。
停止会话不会保存任何内容。 stop 会强制结束进程,因此通常会导致运行期间对世界所做的更改不会留在磁盘上——这是实测结果,而非假设。不要依赖这一点:运行时间足够长而触发自动保存,或正常退出,都会写入数据。对你关心的世界进行任何修改之前,请先执行 save_snapshot。
MIT · 运行实践的经验,按时间顺序记录 · 书面记录的协议 · 用于起步的模组模板
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。