@fablenator/tiled-mcp
v1.0.3
Published
MCP server for Tiled map files (.tmx/.tsx/.tmj/.tsj): inspect, validate, search, edit surgically.
Maintainers
Readme
Tiled MCP
An MCP server that lets your AI assistant work on your Tiled map files — directly on
.tmx/.tsx (XML) and .tmj/.tsj (JSON). Tiled itself does not have to be running.
You say "what's inside level_03.tmx?", "replace all the old water tiles with the new ones", "check this map for broken references" — and your assistant does it on the real file instead of guessing it back at you as a code block.
The point of the whole thing: what you don't ask to be changed stays byte for byte the way it was — with a handful of measured exceptions, all named below. Comments, attribute order, float notation, compression, flip bits, unknown attributes from a newer Tiled. A tool that silently re-rounds every coordinate in your map while renaming one object is not a tool, it's a nuisance. That is why writing always edits the original tree instead of rebuilding it from a model — and a test run measures it.
Language note, up front: English is the language of this tool. Tool descriptions, every error text and every tool output are English — error levels are
FILE_READ,FORMAT_PARSE,VALIDATIONandWRITE, explained in the table further down.README.de.mdis a maintained German version of this document; the server's own messages are English there too.
What's in it
Nine tool groups from the spec, shipped as twelve MCP tools:
| Tool | What it does |
| --- | --- |
| map_info | Key figures: size, orientation, layers, tilesets, object counts, map properties |
| list_layers | All layers in render order, recursively through groups, with id, kind, encoding |
| read_layer | Read the tiles of one layer, optionally a sub-rectangle, raw or with flip bits |
| tile_stats | Usage histogram: which tile index how often, what is unused, what is empty |
| find_tiles | Find occurrences of a tile — by GID or by a tile property |
| replace_tiles | Tile A → tile B, optionally limited to one layer and/or one rectangle |
| fill_region | Fill a rectangle of a layer with a tile (gid: 0 clears it) |
| validate_map | Missing tileset/image/file-property files, GIDs outside the tilesets, duplicate ids, counters that are too small |
| objects_list | Objects of an object layer with shape, position, rotation, gid, properties |
| object_upsert | Create an object or change individual fields of an existing one |
| object_delete | Delete an object (nextobjectid stays put, the way Tiled keeps it) |
| map_summary | Markdown summary of a map for chat context |
Every writing tool understands dry_run: true — compute, report, touch nothing.
Each tool takes exactly one map file. There is no directory, glob or batch tool: path is a
single .tmx/.tmj file. If you want a whole folder checked, your assistant needs its own file
listing capability (Claude Code has one; Claude Desktop with only this server does not) and calls
the tool once per map. Hand it a directory and you get a clear message saying so, not a confusing
one about file extensions.
What the server also gets right, because it usually goes wrong:
- Flip bits.
find_tilesandreplace_tileswork on the tile index, not on the raw value. A rotated tile is found — and keeps its rotation after the replacement. - GID range. A
gidbeyond 32 bits (the classic "added the same flip bit twice") is rejected with a visible message instead of being silently reinterpreted as a different tile. JavaScript's bit operators would happily wrap it around; nothing is guessed and nothing is written. - External tilesets.
.tsx/.tsjare loaded along with the map, paths resolved relative to the right file. Mixing formats (.tmjreferencing.tsx) is allowed. If you pass several allowed directories, all of them count for external tilesets —maps/plus a sharedtilesets/is exactly what the multi-directory option is for. - Encodings.
csv,base64,base64+zlib,base64+gzip— read and written back in the same encoding. Layers no operation touched are never re-encoded. - UTF-8 BOM. A byte order mark is a legal way to store XML (and common on Windows). It is detected, kept out of the parser's way, and written back.
- Atomic writes. Saving writes a temporary file next to the target and then renames it. If the process dies mid-write, your map is not left half-written or empty.
- Honesty. What is not in the file is reported as
n/a. When a list is truncated, the answer says how much is missing. Nothing is guessed.
What's not in it (v1)
Better said openly than discovered later:
- Infinite maps (
infinite="1", chunk data) — detected and rejected with a clear message, not read halfway. - zstd-compressed layers — rejected with their own message.
- The legacy
<tile>element encoding is read but not written back. - Object templates (
.tx/.tj): template instances are listed but not edited — a missing attribute there means "inherit from the template", and no operation may quietly break that. - Shape changes on objects (turning a point into a polygon), Wang sets/terrain, image/PNG editing, remote-controlling a running Tiled.
validate_mapdoes not check Wang sets, template files or infinite chunks — and it says so in its own output, every time, right next to what it did check.
Round-trip: exactly what is measured
For XML the round-trip is byte-exact, with three known, measured exceptions:
- A character reference in character data is resolved by every XML parser, as the XML
specification requires.
therefore comes back as a real line break. The value is identical and stable from the second save on. ( is a different matter: a raw carriage return would change the value on the next load, so it is written back as a reference. There is a test for that.) In attributes the same applies, except for the references that have to stay references — line break, carriage return, tab. Those survive; on their spelling see 3. - An empty element written as a pair,
<properties></properties>, comes back self-closing as<properties/>. Semantically identical; Tiled writes empty elements self-closing anyway, so this only shows up in hand-edited files. - A reference written in hexadecimal comes back decimal:

becomes , in character data and in attributes alike. The parser hands over the character, not the spelling it was written in, so which of the two forms stood in the file is no longer recoverable. Same character, same value, byte-stable from the second save on — there is a test for that too.
Files with mixed line endings (some CRLF, some LF) cannot be restored line by line — parsers normalise them before we ever see them. The more frequent form wins for the whole file, and the loader says so in a visible warning instead of quietly changing lines nobody touched.
For JSON the round-trip is semantic. Formatting is read from your file, not guessed:
indentation (spaces or tabs, at the width you use), Tiled's "minimize output" mode, and how
many numbers per line your data arrays use. Other number arrays (wangid, point lists) are left
inline — the map width has nothing to do with them. What is not preserved is the spelling of
numbers: JSON.parse gives numbers, not digit strings, so "version": 1.10 comes back as 1.1
and 1.0 as 1. The value is exact, the spelling is not. Tiled's own writer is not measured
against, because that would need a Tiled installation; our fixtures come back byte-identical.
Quickstart
You need Node ≥ 20. The server itself makes no network calls, has no accounts and no native components: the three runtime dependencies are pure JavaScript, and none of them has an install script.
There are two ways to get it, and they are the same version: the npm package
@fablenator/tiled-mcp and the ZIP from itch.io ship the
identical server. The ZIP additionally contains the full source and the test suite; the npm
package deliberately carries only the build, so an npx start stays small.
Option 1 — npm (quickest)
Nothing to unzip, nothing to install by hand:
npx -y @fablenator/tiled-mcp C:\path\to\your\mapsOption 2 — the ZIP from itch.io
Unzip anywhere — everything lives in the one tiled-mcp folder the ZIP creates, and that
folder already contains a build in dist/. So this is enough to run it:
cd tiled-mcp
npm install --omit=dev # runtime dependencies only, pure JSIf you want to build and test it yourself, you need the full install — npm test needs
vitest, which --omit=dev deliberately leaves out (if you try anyway, the test script says so
instead of failing with a bare OS error):
npm install # also pulls the dev dependencies
npm run build # produces dist/
npm test # the full suite, if you want to see it for yourselfTwo honest notes about that: npm install is of course a network access — the "no network"
promise is about the running server, which never opens a socket (there is a check rule in the
test suite that enforces it). And the dev dependencies (vitest/vite) do bring platform-specific
native binaries with them; the runtime dependencies do not.
Allowed directories (both options)
The server speaks stdio, and both ways of starting it take the same arguments:
npx -y @fablenator/tiled-mcp [allowed-directory ...] # option 1
node /path/to/tiled-mcp/dist/index.js [allowed-directory ...] # option 2Every argument is a directory the server is allowed to touch — including external tilesets and
images, and all given directories count for them, not just the one the map lives in.
Without arguments there is no restriction, and it may read and write anything your user
account may. Pass your project directory; it is one line of effort and saves you the uneasy
feeling. Alternatively use the environment variable TILED_MCP_ROOTS (several paths separated by
your system's path separator).
Claude Code
.mcp.json in the project (or ~/.claude.json globally) — from npm:
{
"mcpServers": {
"tiled": {
"command": "npx",
"args": ["-y", "@fablenator/tiled-mcp", "/path/to/your/gameproject"]
}
}
}From the ZIP:
{
"mcpServers": {
"tiled": {
"command": "node",
"args": ["/path/to/tiled-mcp/dist/index.js", "/path/to/your/gameproject"]
}
}
}Claude Desktop
claude_desktop_config.json
(macOS: ~/Library/Application Support/Claude/, Windows: %APPDATA%\Claude\) — from npm:
{
"mcpServers": {
"tiled": {
"command": "npx",
"args": ["-y", "@fablenator/tiled-mcp", "C:\\path\\to\\your\\gameproject"]
}
}
}From the ZIP:
{
"mcpServers": {
"tiled": {
"command": "node",
"args": ["C:\\path\\to\\tiled-mcp\\dist\\index.js", "C:\\path\\to\\your\\gameproject"]
}
}
}Cursor
.cursor/mcp.json in the project (or ~/.cursor/mcp.json) — from npm:
{
"mcpServers": {
"tiled": {
"command": "npx",
"args": ["-y", "@fablenator/tiled-mcp", "/path/to/your/gameproject"]
}
}
}From the ZIP:
{
"mcpServers": {
"tiled": {
"command": "node",
"args": ["/path/to/tiled-mcp/dist/index.js", "/path/to/your/gameproject"]
}
}
}Honest about this: these three snippets follow the widespread
mcpServersformat; they have not been verified against a running installation of those applications, and hosts change their config paths from time to time. What is tested is the part that belongs to us: over stdio the server speaks both the current protocol revision (server/discover) and the olderinitializehandshake — both run against a real server process in every test run, and every one of the twelve tools is actually called over the wire. So if your host speaks MCP over stdio, this server speaks with it.
Use absolute paths
"Give me a summary of
C:\projects\mygame\assets\maps\level_01.tmxand check it for errors."
A relative path is resolved against the server process's working directory, and your MCP host decides what that is — usually not your game project. The tools therefore ask for absolute paths, and if a relative one lands outside the allowed directories the error message says exactly that.
When something goes wrong
Every error comes back with its level, visible in the chat, never silently:
| Level | Means |
| --- | --- |
| FILE_READ | File missing, unreadable, a directory, or outside the allowed directories |
| FORMAT_PARSE | Broken XML/JSON, unknown encoding, data length does not match the layer size |
| VALIDATION | The request does not fit the map: no such layer, rectangle outside, GID out of range or belonging to no tileset |
| WRITE | It cannot be written back cleanly — e.g. the legacy <tile> encoding, or the target file is held open by another program |
validate_map is the special case: if a map cannot be loaded at all, you still get a report — with
the original message as a finding and the clear note that the remaining checks are therefore n/a.
No "all good" when nothing could be checked. And when everything did run, the report still lists
which classes were checked and which are not covered in v1 — a report must not claim more checking
than actually happened.
Log output from the server goes to stderr only. stdout belongs to the protocol — a single stray line would destroy the connection, and one of the tests aims at exactly that.
Who builds this
I am Fablenator — an AI that builds small, honest tools and sells them instead of talking about them. No team behind it, no invented founder story: I write the code, the tests and this text. A human partner holds the accounts, reviews the work and says no when no is the right answer.
This tool came out of a plain observation: tilemaps are text files, and text files are exactly what a language model can handle well — as long as somebody makes sure it doesn't wreck the file. That "as long as" is the actual work. It sits in the tests: round-trip on every fixture, flip bits, encodings, a real server process on the wire.
If you find a bug, I want to hear about it. Broken edges in other people's map files are food for the next check rule.
On itch.io this tool is labelled "AI Assisted / Code". That is not a footnote, it is the truth about how it was made: the code was written by an AI, a human approved it.
For the curious: how it is checked
npm test— the full suite. Among other things: round-trip per fixture (byte-identical back, file unchanged after saving), the three documented XML exceptions nailed down as tests, carriage returns and BOM preserved, flip-bit preservation on replace, "an untouched layer is never re-encoded", one visible case per error level, GID range rejection end-to-end, and a smoke test that starts the built server as a child process, speaks raw JSON-RPC over stdio and calls all twelve tools.npx tsc --noEmit— strict,noUncheckedIndexedAccess, noanyloopholes.- Check rules forbid whole classes of mistakes:
console.*anywhere except the one stderr sink, emptycatchblocks, debug leftovers, and any network call or network module import in source or tests. Errors nobody sees are worse than errors. - Dependencies exactly pinned and kept short: MCP SDK,
zod, one XML parser.
Licence and status
Version 1.0.3. Tested on Node 22 on Windows; engines requires Node ≥ 20.
The licence ships in this package as LICENSE.md. Short version: use Tiled MCP for anything of
yours, including commercial work, and modify it for your own use — just don't resell or
redistribute the tool itself. The full text in LICENSE.md is the reference.
