dayz-agentic-modding-mcp
It is an MCP server for DayZ mod development: build, test, inspect, automate, and publish a mod, with a local knowledge index and live server/client tooling.
Read and validate
dayz-mcp.toml/dayz-mcp.local.toml, discover game/tools, and report project status and missing pieces.Pack, sign, and lint mods; generate and validate
.layoutfiles; skip lint only deliberately.Start/stop/check a test server, read and filter logs, and get pass/fail verdicts against counters, warnings, forbidden strings, and errors.
Compile-check the client half and run a live client: screenshot, move/look/press/trigger, type, chat, and judge its
.RPT.Use the bridge for world control: query state/entities, spawn/delete/teleport, set health/quantity, attach/detach/move items, power devices, run actions, set time/weather, and wait for readiness.
Inspect and interact with the UI: walk/find widgets, click, set text, load/unload layouts, and build previews/galleries.
Build a local SQLite knowledge index of game, dependency, and project code; search declarations, bodies, overrides, callers, and scope answers to selected mods.
Export
.blendmodels, binarize models, convert textures, and check assets with artifact-based verdicts.Publish/update Steam Workshop items, manage listing metadata/required items/previews, inspect Workshop status, and query live servers for running mods.
Read or change the stand's signature policy, build/clear/check the bridge, and manage long-running jobs and artifacts.
Provides Blender asset export for DayZ modding, using the Blender executable to export models into the mod project.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@dayz-agentic-modding-mcp Build my DayZ mod and run a compile check"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
dayz-agentic-modding-mcp
An MCP server that lets an agent build a DayZ mod, check that the client compiles, run a test server and get a structured verdict instead of a log -- and then publish the result to the Steam Workshop.
The server does the work itself: it calls FileBank, the signing tool and the diagnostic executable directly. A project does not need its own build script.
It ships with a Claude Code skill, skills/dayz-modding: what the sources cannot
tell an agent about Enforce Script, configs, layouts, persistence and the toolchain,
each fact measured on a server and a client. The repository is a Claude Code plugin,
dayz-agentic-modding, that installs both (see Install).
A community tool: not affiliated with or endorsed by Bohemia Interactive, the maker of DayZ.
Why a profile
The server knows nothing about any particular mod. Everything specific lives in a two-part profile:
dayz-mcp.toml— portable, committed to the mod repository: which mods to pack, what a healthy boot looks like.dayz-mcp.local.toml— machine-specific, never committed: where the game, the tools and the test stand live.
Mixing the halves is rejected on load: that is how a repository stops building on
anyone else's machine. Start from dayz-mcp.example.toml.
A mod is declared once, by name: sources in <root>/Name by default, output
@Name/addons/Name.pbo. build.sources can redirect a mod's source elsewhere
(e.g. "." for a mod whose config.cpp sits at the repository root itself).
The portable half — dayz-mcp.toml
Key | Meaning |
| what to call this project |
| one entry per mod, by name; source, pbo and |
| where a mod's source really lives, relative to the profile ( |
| what must never be packed. Listing it replaces the default wholesale; the default is |
| pack a filtered copy instead of refusing when something excluded is present — the layout a root-layout mod needs |
| the directory every model path resolves against, relative to this file. It must contain the mod's prefix folder. Required by the model tools and by nothing else — see "The asset pipeline" |
| a PowerShell script run before packing, for projects that generate code first |
| widget classes your own scripts declare (a modded map widget, say), so the layout lint does not refuse them as unknown |
| where the layout generator's tokens file lives, if not the default |
| the line the mod prints when it has finished loading — see below |
|
|
| warning budget; omit the key to disable the check |
| substrings that make a run bad regardless of anything else |
| regexes marking script errors that belong to you, used by the client compile check |
| extra engine noise to ignore, on top of the built-in list |
| launch the client with |
The machine half — dayz-mcp.local.toml, never committed
Key | Meaning |
| the game installation; discovered automatically if absent |
| a dedicated server installation (Steam app 223350) to boot the stand from, instead of the diagnostic executable inside |
| DayZ Tools; discovered automatically if absent |
| the Blender executable, for |
| the prepared test stand. The server boots against it and its logs are read from |
| the server config filename inside the stand (default |
| the port |
| client window size, |
| mod folder names, resolved under the game's own |
| full paths to anything else to load, for mods that do not live in |
| folder names to route to |
The ready line
server_start finishes when expect.ready_line appears in a log written after
that boot began. It is the one thing the server cannot work out for itself, and
without it two things change: nothing can be waited for, so the boot job starts
the server, confirms it is still alive a moment later and finishes saying so;
and log_verdict has no line to read counters off, so expect.counters never
matches anything. Errors, crashes and the warning budget are still judged. A
profile without a ready line is supported, not broken — project_open says so
in its notes.
Related MCP server: DayZ API MCP Server
Tools
Tool | What it does |
| read the profile, discover the game and tools, report what is missing |
| current project, running server, recent jobs |
| pack and sign every declared mod; returns a job id. Refuses a second build of the same project while one is still running |
| start the test server, finish when it is ready. Refuses if the mission the config names is not under |
| pid, whether the process is alive, whether the log is growing (sampled |
| stop the server this session started: closes its console window so it saves the world and exits, kills it only after 30 s or with |
| read — or deliberately change — the stand's |
| run the diagnostic client and read its logs |
| pass/fail with reasons: counters, forbidden strings, warning budget. |
| last lines, optionally filtered; same two sources |
| status of a long-running job |
| wait for a job to finish |
| retrieve outputs from a completed job |
| pack the bridge mod, whose sources ship with this server ( |
| is the bridge inside the running game still ticking: reports the tick number and whether it advanced over |
| discard the command stuck in the mailbox, naming what it threw away. Refuses while the bridge looks alive unless |
| wait until the bridge inside the game is actually claiming commands. Call it once after the boot job finishes, before the first world command — see "server ready is not bridge ready" below |
| snapshot of the world from the bridge's once-a-second publish: players, position, health, hands. Free with no arguments; with |
| create an item on the ground (with no lifetime, so it cannot vanish mid-check), in the player's hands, in their inventory, or attached to the item they are holding ( |
| move the player to |
| set |
| attach an item the player already has to another of their items. |
| take the attachment out of one slot and put it in the player's |
| move an item the player already has between |
| switch a device on or off wherever it is on the player — worn, in a pocket, in hands — through its energy manager. |
| delete objects of one class nearby. Requires the class; never deletes a real player |
| which objects are nearby, not how many: class, position, distance and health for each. A page, and it says so — the true total comes back beside the list |
| move the world clock. Every field left at -1 keeps its current value, read back from the engine first, because the engine sets a date as five numbers at once |
| move overcast, rain, fog, snowfall or wind. A nudge, not a lock: the engine keeps simulating weather afterwards, and both the tool and the mod say so |
| run a mod's own action through the engine's gate — see below |
| the escape hatch: an arbitrary verb through the same transport, marked non-standard in every answer |
| start the game client and connect it to the stand; returns a job id. Always windowed ( |
| pid, window geometry, whether the window is minimized or in front, the background setting, the player count, and whether a virtual controller is attached |
| stop the client this session started, and unplug the virtual controller |
| capture the client's window to a PNG, with |
| walk the character with the left stick. Analog, and the only tract that moves the character at all. No focus needed |
| turn the camera with the right stick. No focus needed |
| one gamepad button, from a closed table of fourteen names. No focus needed |
| pull an analog trigger — |
| put a line in chat — delivered server-side by the bridge, so no keyboard, no window, no focus |
| type into a client-side input field with real keystrokes. The only tool here that takes the foreground, and it says so in its answer |
| judge the live client by its own |
| what the client's interface is doing: open menu class, cursor, dialog. Free — republished every tick |
| the client's widget tree: path, class, name, visibility, screen rectangle, depth, text and text size (engine pixels, TextWidget family only). A page, and it says so. |
| the same walk, filtered in the client so the whole tree never has to travel — and filtered during the walk, before the 300-node ceiling, so a name is found wherever it is rather than only in the first page. |
| press a widget. |
| what the real mouse is over right now, from the engine's |
| write into an edit box, and read the value back out of the widget |
| show a layout file in the client under a host of the bridge's own size, and list what the engine made of it. |
| remove the preview |
| a layout as the engine draws it, in one folder: a screenshot, every widget's rectangle, and the rectangle checks, with an HTML report over all three. |
| every entry of the project's preview index ( |
| generate every |
| judge the Enforce Script without packing or booting anything. |
| build or refresh a layer of the API index; returns a job id. |
| what each layer holds, how old it is, and whether it still matches what is on disk |
| find a class, method, constant, enum or config class by name |
| one declaration in full: signature, members, inheritance chain, and the source itself — read straight out of an archive if that is where it lives |
| who overrides this class or method |
| who calls this method or builds this class — every call site, with the class and method that made it |
| export a model out of a |
| binarize a mod's models from their MLOD sources, judge what came out, and only then put it in the mod; returns a job id |
| judge the models and textures a mod already ships. Builds nothing, needs no DayZ Tools, answers in milliseconds |
| convert one texture between |
| upload a built mod to the Steam Workshop; returns a job id. The content is the built |
| the built folder against the public listing of its item: pbos, which are unsigned, keys, links, size, when the newest pbo was built; the item's title, last update, size, visibility and subscribers; and |
Layout primitives
Most of layout_build's JSON vocabulary (frame, panel, vbox, label, button, ...)
reads as what it says. Two are worth a sentence each, because what they emit is not
obvious from the description:
Primitive | Emits |
| a |
| a |
Windows and the ms-gamebar dialog. The first time a virtual controller is
attached, Windows tries to open Xbox Game Bar. On a machine that has it, nothing is
seen; on one that does not — LTSC, Server, or the Xbox packages removed — the shell
cannot resolve the URI and puts up "Get an app to open this ms-gamebar link", once
per attach. Nothing here causes it and nothing here can prevent it: the shell reacts
to the device arriving, and does the same for a physical Xbox pad. So the call that
plugs the pad in says so, with two remedies the machine's owner can apply — turning
off Settings → Gaming → Xbox Game Bar → "Allow your controller to open Xbox Game
Bar" (HKCU\Software\Microsoft\GameBar\UseNexusForGameBarEnabled = 0), or giving
the URI a handler that does nothing.
Signatures, and why the engine's own message sends you the wrong way. Under
verifySignatures = 2 a stand refuses every client with code 118 and "missing
dta\bin.pbo" — a vanilla file name, with no mention of signatures at all. The
cause is usually the keyring: this tool launches the diagnostic executable out of
the CLIENT install, so the engine reads keys beside that executable, while
dayz.bikey — the key that signs the game's own pbos — ships with the separate
DayZServer install. A keys folder that is missing, or holding only a mod's own
key, leaves the server unable to verify anything, vanilla included. client_start
refuses and names which of the three it is; server_start only warns, because a
headless boot with no client is still useful. An unsigned pbo on the client's
-mod line is named the same way — including this server's own bridge, which is
packed unsigned on purpose.
Three limits the engine imposes on the UI tools, none of them worked around:
a plain TextWidget has SetText and no GetText anywhere in enwidgets.c, so a
label's string cannot be read at all — what a mod's interface MEANS stays a question for
the server-side bridge, where the data is real. A script-level click reaches only the open
scripted menu, because Widget has SetHandler and no GetHandler; via="cursor" is
there for everything else. And the client has to load the bridge: one pbo carries both
halves, so a profile listing it under mods.server_only keeps it off the client's -mod
line — that case is refused by name rather than answered with an empty tree.
job_wait is the tool meant to wait, and its timeout is capped at 600
seconds however large a value is passed. Two other tools sleep: server_status
samples the log twice, pulse_seconds apart, capped at 10 seconds — that pause
is how it tells a slow boot from a hung one — and bridge_status samples the
bridge's tick twice, window apart, capped at the same 10 seconds, for the same
reason. Everything else returns immediately; work that takes minutes happens
behind a job id.
The bridge mod
bridge_build packs bridge/ from this server's own repository into
@DZMCP_Bridge beside it. It is the server's mod, not yours: one copy serves
every project, nothing is written into your repository except the job record,
and no project's signing key is used — a -serverMod pbo is never handed to a
client to verify, so it is built unsigned and its output folder is kept free
of signatures and keys.
Building it does not load it. That stays your profile's decision, because the
bridge is an extra pbo in the stand and a run without it has to remain possible.
To attach it, add two lines to dayz-mcp.local.toml (the same two
bridge_build's job summary prints):
[mods]
extra = ["<path printed by bridge_build>/@DZMCP_Bridge"]
server_only = ["@DZMCP_Bridge"]server_only is what routes it to -serverMod instead of -mod. Without it
the stand boots perfectly well and bridge_status reports that the bridge never
wrote any state — which is true, and easy to mistake for a broken bridge.
bridge_status also reports the command mailbox. Inside the game only the mod
empties it, by claiming the command; on this side bridge_clear and
server_start's pre-boot clearing do. So a command sent while the stand was
down, or before the bridge was attached, is not discarded and does not expire on
its own — it keeps blocking
every later send, and a stand booted outside these tools would pick it up.
server_start clears both transport files before every boot, so a server
started through this tool never runs a command from a previous session; that is
hygiene, not a substitute for knowing the command is there. The state comes back
as stale_command, and
bridge_clear() is the way out of it. Clearing is a separate tool on purpose:
throwing away a queued command is a decision, not something a status check
should do behind your back. It refuses while the bridge looks alive unless you
pass force=True, and either way it reports the command id it discarded.
What bridge_status can tell apart
The tick alone is not enough to judge a bridge, because it restarts at 0 every
boot while the state file survives in the profile directory. Every answer
carries the channel's own verdict in heartbeat, and the four are genuinely
different facts:
|
| meaning |
|
| the tick moved within one session — the only |
|
| a new world came up between the two samples: alive, not frozen, and anything sent to the old session is gone |
|
| the same world seen twice, not moving — a script-side problem, so |
|
| a sample could not be read (or |
Every answer that read a sample also carries session_id — the live world's
id — and a restarted answer carries previous_session_id too, so a caller can
say which world went away.
no_server, stale_command, no_state_file, invalid_state,
unreadable_state and outdated_bridge come before any of that: nothing is
running, a command is wedged, the mod is not loaded, the state document is valid
JSON with a named field wrong (it says which, and checks twice before saying it),
the file never parses at all, or it parses but predates this server's protocol
(rebuild it with bridge_build).
The world commands
The world tools talk to the bridge over two JSON files in the server's
-profiles directory: a command mailbox (written atomically from this side,
deleted by the mod as its claim) and a state file the mod overwrites once a
second. Enforce Script has no rename, so the mod cannot write atomically —
the reader tolerates torn writes instead, and one failed read is never news.
Four facts, all measured on a live stand, decide how to use them:
Server ready is not bridge ready. The bridge starts claiming commands
tens of seconds after the server reports ready — the spread observed so far
is 18–38 seconds, and it varies boot to boot.
A command sent into that window is not rejected — it is claimed late and
completes after the caller gave up. So: server_start, wait for the boot job,
then world_ready(), then commands. Every world tool also refuses upfront
if the tick is not moving, naming world_ready as the remedy.
Every argument value crosses the wire as a string. The mod's parser is
strict: a JSON number anywhere in args rejects the whole args block. The
tools stringify numbers and booleans themselves and refuse values with no
faithful string form (lists, dicts, None). Positions travel as one string,
"x y z".
A refusal is a result. The mod's own sentence comes back verbatim as the error: "no player is on the server", "the class does not exist", "the action's own Can() said no". Nobody connected is the normal state of a headless stand, and every verb that needs a player says so instead of silently doing nothing.
The session id protects against yesterday's command. Every command carries the session the bridge most recently published; the mod refuses any command addressed to another session (or none) without executing it. A command written while the stand was down can therefore never fire into a freshly booted world. The tools stamp the session automatically — it only matters if you write the mailbox by hand.
Measured on a live stand, three boots. world_time_set(hour=3, minute=7)
moved the clock to 2026-09-20 03:07 and left the date where it was;
world_weather_set("fog", 0.9, seconds=2) took the published fog from 0.085 to
0.900 and held it; world_entities(pos="7500 0 7500", radius=150, limit=5)
listed 5 of 171 objects with truncated: true. Distances came back at 320 m
for a 150 m radius until they were made horizontal, which is what the engine's
own radius test measures.
Attachments, carrying and power are engine operations, not mod behaviour
world_attach, world_detach, world_move and world_power ship in this
bridge rather than in a project's own copy of the dispatcher, and the line is
worth stating: taking an item off a slot, putting one on, carrying one in a
pocket instead of a hand, and throwing an energy manager's switch are the same
calls whatever mod drew the device (FindAttachment,
ServerTakeEntityAsAttachmentEx, ServerTakeEntityToInventory,
ServerDropEntity, GetCompEM().SwitchOn). The rule under world_exec is
about behaviour a mod defines; none of this is.
They exist because a worn device could not be reached at all: world_spawn
attaches a NEW item to whatever is in HANDS, world_set knows health and
quantity, and world_action needs the item in hands too — so on a live stand a
flat battery could not come out, a fresh one could not go in, and nothing could
switch a device on, which left every action whose condition reads IsWorking()
untestable from outside the game. All three name the item the only way a tool
that has never seen it can: "hands", "player" (the character's own worn
slots), or a config class looked up on the player — hands first, then worn
attachments and cargo, recursively, first match by IsKindOf. world_move
covers the other half of the same gap: carry it, do not hold it is where a
test of a worn or pocketed device starts, world_spawn puts an item in one
place and leaves it, and the move between hands, inventory and ground is a drag
inside the inventory screen — a gesture no tool can make.
Each one reads the result back out of the engine rather than reporting the
call's own bool: whether the slot is empty now, whether the item's own
inventory location says it landed in that slot, whether the manager says
switched-on and working. That
is the same rule as ui_text reading its field back, and for the same reason —
SwitchOn() does nothing at all when the device cannot switch on, and says
nothing about it.
An inventory move lands one tick after the frame that asked for it
(measured 2026-09-07): ServerTakeEntityToInventory answered true and the slot
still held the battery in that same frame, and the very next command found the
slot empty and the battery in cargo — so a read-back taken where the call
returns reports a failure for a move that worked. attach, detach and move
defer their own verdict by a tick, the same DeferCompletion(1) ui_load uses
to let a widget reach its first layout pass, and cost about a second more than
the other world verbs for it. attach spends that tick on more than a yes: it
names the slot the item landed in, read off the item's own inventory
location, so a call that named no slot still comes back with the name
world_detach will want.
One tick is not always enough (measured 2026-09-08). A move whose source
is the hands does not go straight into the inventory: HumanInventory turns it
into a HandEventMoveTo and posts it to the hand state machine, which applies
it on the player's next command-handler frame — later than the bridge's next
tick when the player is busy. Attaching the item held in hands to a player slot
answered failed once for a move that had in fact worked. So the three verbs
look again, up to five ticks (still inside the mod's own 30 s hard limit and
well inside this side's 45 s wait), and the answer names the wait it spent:
... after 2 tick(s). A move that never lands is still refused by the mod in
its own words, with the tick count, rather than by a timeout.
Actions, and why there is no verb dictionary
A semantic verb like "hand in the sample" lies: in a real mod the same words
mean different things depending on which device is near, the player's faction,
and what is already unlocked. That context is not enumerable, so the bridge
does not try. world_action takes an action's class name, a target and the
held item, and asks the engine to run it through its own gate — the same one a
key press goes through. Applicability is decided by the action's own Can(),
and its refusal is a meaningful test result, not a tool failure. The
distinguishable answers: manager busy, player already acting, player
sprinting, unknown action class, and "the action's own Can() said no".
"Accepted" is not success either — the command stays running until the engine
actually releases the action, and every failure path releases the manager so
the player can still act afterwards.
world_exec is the escape hatch
Anything a mod exposes that is not an action — "how many points in the
faction pool" — goes through world_exec(verb, args): an arbitrary verb over
the same transport. Every answer is marked non_standard: this server does
not know the verb, does not validate it, and does not answer for what the mod
does with it. A verb the bridge build does not know comes back listing the
verbs it does. A project that needs its own verb edits its own copy of the
bridge's dispatcher (the comment above KnownVerbs() in
bridge/scripts/5_Mission says exactly where); there is no registration
machinery on purpose — a verb this server typed and validated would be a verb
this server answers for.
The client: three input layers, and why there are three
The bridge reaches the server. What it cannot do is look at the client's
screen or act through the client — walk a character across ground, open a
menu, fill a field a mod drew. The client_* tools are that, and they use
three different tracts because no one of them can do the other two's work.
Every line below is a measurement against a live client, not a design
intention.
Tract | What it does | Needs the foreground |
the bridge ( | the world, and text into chat | no |
a virtual gamepad, ViGEmBus ( | movement, camera, and some interface | no |
real keystrokes, | text into a field that exists only on the client | yes, and it takes it |
Keyboard emulation does not move the character, and window messages do
nothing at all. SendInput scancodes with the foreground verified: 25 s of
forward, 0 m. PostMessage/SendMessage WM_KEYDOWN into the main window
and its children: 0 m, and no reaction from the menus either. The engine
reads movement from raw input and ignores emulated keys, which is why no
tool here offers a window message.
The virtual gamepad does move it, unfocused, and it is ANALOG — the reason it stays even where a key would do. Measured in one run with a third-party application holding the foreground throughout:
stick fully forward, 10.0 s -> 38.40 m (3.84 m/s)
stick at 0.3 forward, 8.0 s -> 11.34 m (1.42 m/s)Same tract, same character, 2.7× the speed from stick deflection alone. "The character is walking, not running" cannot be expressed with a key, which knows only on and off. In the same run the character walked about 141 m of its own accord, and the mod's own count of objects within 10 m of it went 1 → 0 → 1 as it left the spot and came back — a state change caused by presence, which a teleport cannot produce.
Some of the interface answers the pad, and some does not. Measured, with
the game window behind another application the whole time: back opens and
closes the inventory, start opens the pause menu, b closes it — all at the
default 0.1 s tap, so a tap is long enough for the engine to latch. But a
moved nothing, at 0.1 s or at 0.5 s, and neither did the d-pad inside those
screens: the client did not switch to controller-navigation mode, so there was
no focused element for a confirm to act on. Treat menu dismissal as a
gamepad job and menu confirmation as unproven.
The eyes need no focus either. A capture is a live frame with the window
at the very bottom of the z-order (lit_fraction 0.9997 unfocused, 0.9997
focused in the same session). The one state that defeats them is a
minimized window, whose client area collapses to 0×0 — refused with a
reason rather than saved as a valid-looking empty picture.
All of that background behaviour rests on one client setting, pauseMode
(GAME → UPDATE IN BACKGROUND). At the value measured here the client keeps
drawing and simulating while unfocused, which is why the frame is live and the
stick still moves the character. At "no graphics" both would stop silently —
a frozen frame looks exactly like a live one. So client_start and
client_status READ that setting and warn; they never write it, because it
belongs to whoever owns the machine.
Two more profile settings ride along on the same launch line.
client.file_patching adds -filePatching (the stand's server config needs
allowFilePatching = 1 or the client is refused at connect), and
machine.window — or a per-call window= override — adds -x/-y.
client_type is the only tool that takes the screen, and it is honest
about it: the answer carries foreground_taken and a sentence saying the
person at the machine could not type into their own window while it ran. It
verifies the foreground with GetForegroundWindow after asking for it, because
SetForegroundWindow returns success having done nothing when Windows refuses
— and typing blind sends the keystrokes into whatever window the person is
actually using. When the foreground cannot be had, nothing is typed and the
refusal names the process holding it.
ViGEm is emulation of a real device and this is a test stand. The driver is signed and installs without a reboot, and the gamepad is a new device rather than a filter over the machine's own keyboard and mouse — a filter driver was tried here once and cost the machine's owner all keyboard and mouse input until it was unwound by hand. None of that is a promise about anticheat on a live server, and nothing in this phase makes one.
The knowledge index
An agent writing a mod keeps asking the game the same questions: is there such
an API, what is it called, where is it declared, who overrides it. Answering
them meant unpacking scripts.pbo and sweeping the text — and every session
paid again. knowledge_* turns that work into a question.
It is a plain SQLite file in the project's own .dayz-mcp/, built by this
server out of the game, the mods a project declares and the project's own
sources. No embeddings, no external service, no key.
Three layers, and why their rhythms differ
Layer | Source | Goes stale when |
| the game: | the game updates |
| the archives of the mods the profile declares, read without unpacking them | a dependency is updated, or the declared set changes |
| the mod's own sources, read where they lie | every edit |
One index built in one go would be wrong within a minute of being right: the
game moves a few times a year, a dependency a few times a month, and the
project between one agent turn and the next. So each layer is built, aged and
measured on its own, and every build is incremental — unchanged sources are
skipped by size and modification time, and only=[path] skips even the walk
that discovers them.
An answer carries the age of the layer it came from
Staleness is measured, not guessed: a layer records the size and modification time of every source it read, and that is compared against the files as they are now.
Every answer names the layers it used and how old each one is. An answer with no results names every layer it searched — "not found" is worth exactly as much as the layers behind it are current.
The project layer's freshness is measured on every search, whether or not it contributed. That is the dangerous case: an agent adds a class, asks about it, and a layer built a minute ago says "not found" — a confident statement about code that exists.
A search over a layer that was never built is refused, and the refusal names the call that builds it. "Not found" and "not looked" are different facts, and only one of them is safe to act on.
Narrowing carries the same trap one level down, so an empty narrowed answer reports where the name does exist: asking
kind='class'about a name the game declares only in a config gets a true "no" that reads as "the game has no such class".
Config classes live under kind='config', not kind='class'. Counted in this
machine's own index of the game: 88 102 config classes against 43 595 script
declarations of every kind put together, so mixed into one kind they bury every
script answer. Separated, "does the game have an item class called X" is a
question you can ask exactly.
What it does not answer
The index answers what exists: class, method, signature, where declared, who
overrides. It does not answer what is right — that modded class X extends X compiles and silently fails to apply, that _co costs the alpha channel,
that binarize takes directories rather than files. None of that is derivable
from the sources; it was learned the hard way and lives in the modding skill and
in the mod itself. The index does not try to replace either, and it does not try
to understand what a field means or why a class is there.
Semantic search is deliberately not here
The decision was made by measurement, not caution: every lookup that shaped this server's earlier phases was a lookup by name. And an embedding index would break the rule the rest of this server keeps — install it and it works, with no external service and no key. The predecessor project this one deliberately did not build on documents its knowledge layer as local and free while its code imports a paid embedding client, fails without a key and carries hard-coded prices. Its two search tools also hang forever, because the client behind them was created without a timeout; hence the ceiling every search here runs under. If exact search turns out not to be enough, semantic search is a separate phase with one condition: the model ships inside the delivery.
The measured numbers
On this machine — the game with 2810 script files, 35 installed mods, one real project of 41 sources — through the tools, not their internals:
Build | Result | Time |
| 2927 sources, 131 697 declarations (41 gave nothing) | 70.2 s |
| 8 archives, 10 925 declarations | 0.9 s |
| 523 archives, 204 768 declarations, 3 archives unreadable and named | 139–147 s |
| 41 sources, 1196 declarations | 0.12 s |
The index on disk: 74.7 MB for a real project's three layers; 110 MB for the 523 dependency archives on their own. Those archives are 92 GB, and none of them is unpacked.
Call sites are what the index pays for. The game's own scripts hold 43 579 declarations and 113 703 call sites, and recording the second set roughly doubles the index: measured on the game layer alone, 23.8 MB and 3.7 s to build without them, 49.6 MB and 4.5 s with. That is the price of being able to answer "who calls this", and it is stated here rather than discovered later on a full disk.
Answer | Time |
| 4.2 ms end to end, of which 3.0 ms is the project walk |
| 3.2 ms of query |
| 4.2 ms |
| 0.38 ms of query |
| 277 ms of text checks, 7 ms of index checks |
| 6.8 ms |
| 41 ms (110 ms on the first call after a build) |
Incrementality, on the real project: a full rebuild 136 ms; one edited file
found by the walk 8.8 ms (15×); the same file named through only= 5.8 ms
(23×). On a 2810-file tree the walk dominates and only= is worth far more
— but on a project of this size, 15× is what an ordinary rebuild actually buys.
The ceiling bites for real: a query measured at 77 ms, run under a 19.3 ms ceiling, was stopped at 19.9 ms, and the connection went on answering.
The asset pipeline
Getting a model from Blender into a mod is ten steps, and until this phase all of them were run by hand. The value is not in launching the tools. It is that every tool in this chain is structurally unable to report failure, and each of those silences had already cost days.
Measured on the real binaries, not assumed:
What happened | What the tool returned |
| 0, an empty output directory, not one line of text |
| 0, an ODOL of 46,190 bytes where a correct build is 58,644 |
|
|
the Blender exporter with its own default arguments |
|
So the rule this whole namespace is built on: the verdict is read off the artifact, never off the tool's report. The exit code is recorded and believed in neither direction.
The root is declared, not assumed
binarize has no project-root option at all — the full switch list was
enumerated against the real binary. The root is the working directory of the
process. The same command, the same input, a different directory, and out comes
a valid ODOL with plausible texture paths that the engine renders untextured,
with a success code and no complaint. The exporting Blender add-on has the same
root in a preference of its own, remembered from whatever project was open
last: on the machine this was developed on it pointed at a directory from an
unrelated session, and against a wrong root the add-on does not fail either —
it strips the drive letter, keeps the rest, and writes paths that look like
paths.
build.project_root is that directory, stated once in the portable half of the
profile. The server makes it the binarizer's working directory — through a
drive letter of its own, see below — and pushes it
into the add-on for the duration of the run, so what the add-on has stored
decides nothing (it is reported, so you can go and fix it). That is what makes
a wrong root impossible rather than detectable, and it is why the key is
required before anything model-shaped will run at all.
The refusals it produces happen before a process exists — measured at 0.0003 s — and a refused build leaves the model the mod already ships byte for byte untouched.
A drive of its own for every build
binarize reads the whole drive before it reads a model. Read off the
running process: for the first minutes its one busy thread sits inside
FindNextFileW with a handle on the root of the drive its working directory is
on, and nothing is read from the model until that walk is over. So how long a
build takes is the size of the disk, not of the model:
| One small model | Five small models of another project |
the model root itself, a folder on a large drive | 650.8 s | 510 to 540 s each |
a drive letter substituted for that folder | 0.1 s | 0.1 s each |
asset_build therefore maps a free drive letter onto build.project_root with
subst, starts binarize from it with the source named on the letter, and
removes the letter when the process is gone. A letter whose root is the
declared root is that root, so nothing about the artifact changes: C3 and C4,
the two checks that fail from a wrong working directory, both succeed, and the
files come out the same size to within three bytes. The job's artifact records
the directory the tool really ran from (cwd).
Every build maps a letter of its own and removes only its own — one that already points at the same root may be another server's build in flight. With no letter free the build runs from the folder itself, as correct as before and as slow, and its summary says so, because minutes spent walking a drive look exactly like a hang. The walk goes through junctions too, so whatever is linked into the root is walked with it: an unpacked game tree of 103,359 files turned the tenth of a second into half a second.
Twelve checks on the artifact, and four of them refuse
asset_check runs them without building anything and without DayZ Tools,
because a fresh clone must be able to ask whether what it is shipping is
healthy. Four refuse: a built model is there and is an ODOL (C1), no reference
escapes the mod (C3), a material was actually inlined (C4), and nothing already
binarized is offered back to binarize (C10). The rest warn: dangling
references, an rvmat pointing into another mod, a transparency lost to DXT1
(C7), an animation that never reached the artifact, a model.cfg that is not
the one the artifact was built from, a structural fingerprint that no longer
matches what the last build deployed. Every finding says what to do.
C4 is the one worth knowing about. When binarize resolves an rvmat it copies
that material's own stage textures into the model — fresnel,
#(argb,8,8,3), env_land_co.paa, _nohq, _smdi — strings no MLOD
contains. Six artifacts out of six were separated correctly by that one test,
and it found a broken model on this machine that nobody knew about.
The Blender step is optional
asset_export is the only tool here that needs Blender, and everything
downstream works on a .p3d from anywhere — a hand export, a partner's file, a
model committed years ago. A machine with no Blender builds and ships a mod
perfectly well; the refusal says so rather than presenting it as a broken
installation. It does need the exporting add-on to be enabled in the Blender it
finds, and it never writes Blender's user preferences back (verified: the
preferences file was byte-identical after every run).
Export and build are two calls rather than one, because each half has its own verdict and a build refused by one and allowed by the other is not a decision.
Byte-equality is never promised
Neither half of this pipeline is reproducible, and the design says so instead of pretending:
The export. Seven exports of one unchanged source file — three from one session, three from another, and one made by hand in the GUI months earlier — gave seven different SHA-256s at a constant 334,032 bytes. The difference is the order of one internal block.
binarize. Four runs on one unchanged input gave three different results: the size moved by 5 bytes and two 8-byte fragments leaked out of compressed regions.
So a model is never cached or compared by content hash. What is compared is a structural fingerprint — the file's kind, its LOD count and its set of names. Across all seven of those exports that fingerprint was one value.
The measured numbers
One small model, on this machine, through the tools:
Step | Result | Time |
| MLOD, 334,032 B, 5 LODs, clean | 2.1 s (about 8 s on a cold start) |
| ODOL v55, 58,646 B, 4 LODs, all five C4 markers | 0.1 s in |
| 1 model and 10 texture pairs judged | milliseconds |
| one PNG to DXT1, 50,764 B | 0.52 s |
a refusal on a wrong root | before any process is started | 0.0003 s |
Both logs are almost entirely boilerplate, and what is muted is counted rather
than dropped: Blender's 169 lines came down to 4, and binarize's 91 to
6 — one of those six being the model's only genuine complaint.
Chained end to end, the export and the build reproduced a model that had been made by hand months earlier: same kind, same 4 LODs, the same 50 strings, and a size one byte apart.
What none of it answers
Whether the model looks right, is scaled right, is wound right, has a collision. Nothing outside the game answers that. C1–C12 shorten the road to it; they do not replace it.
The Workshop
workshop_publish is the last step of a mod's life on this machine and the
only tool here whose effect is somewhere else, so it is built the way
server_signatures is: everything that could publish the wrong thing is
refused before a byte leaves, the answer says what is about to happen, and
the item is read back afterwards rather than taken on Steam's word.
What goes up is what mod_build made. The @Name folder, whole --
addons/ with the pbos and their .bisign, keys/, mod.cpp, meta.cpp --
which is also what Publisher uploads. A junction or symlink inside that
folder is refused rather than followed: Steam would upload the tree behind
it, and the tree behind it is usually the sources.
meta.cpp says which item. Publisher leaves three lines in the folder
it published from (protocol = 1; publishedid = N; name = "...";) and the
launcher reads the id back out of them. The copy subscribers receive adds a
fourth, timestamp = <int64>;, and CRLF line ends (read off a subscribed
item 2026-09-19; the fourth decodes as the .NET DateTime.ToBinary() of the
moment Publisher wrote the file, and nothing reads it back); the reader here
takes either shape, and this server writes the three lines the id lives in
-- Steam took that file for an update the same day. Otherwise it does the
same. With the
file, the item it names is updated; without it, an item is created and the
same three lines are written into the folder before the content goes up,
so subscribers receive the id inside the folder, and Publisher can carry on
from a folder this server created. Keep that file with the sources -- a
deleted build folder takes the item id with it. A meta.cpp that exists but
carries no readable id is refused, not treated as absent, because a guess
there publishes a duplicate.
A new item is private. Unless visibility says otherwise, an item is
created hidden, so nothing is public before its page has been looked at;
workshop_publish(mod, visibility="public", content=False) opens it without
touching its files. An update leaves untouched whatever it was not given:
no title means the title stays.
Required items are a section, not a sentence. The DayZ Launcher pulls an item's
dependencies from the Workshop's Required Items list, not from its description, so
workshop_publish(mod, requires=[...], content=False) puts Workshop ids there
(ISteamUGC::AddDependency) and remove_requires takes them off. The job reads the
item's current list first, through a details query with children, changes only what
differs, and reads the list back into the summary -- a repeated call changes nothing.
A refused change fails the job by name, after any listing update in the same call has
already gone through, and the summary says so.
workshop_publish(mod, previews=[...], content=False) adds pictures to the item's
screenshot strip (each under 1 MB, png/jpg/gif, relative to the project or
absolute). Every call adds: Steam keeps what the page already shows, so a
picture is sent once, and taking one down is done on the item's page. The
binding is the flat export SteamAPI_ISteamUGC_AddItemPreviewFile of the game's
DLL, measured 2026-09-27: five images accepted in one update, shown on the page.
content=False edits the listing and nothing else. Title, description,
preview, visibility and tags go up; the folder's files do not, so the build
folder may hold anything -- an unreleased build, no pbo at all -- and only
its meta.cpp is read, for the item id. It is the way to fix a description
without shipping whatever happens to be built. It cannot create an item,
because an item without content is nothing, and it refuses when nothing was
given. Sending the content again is cheap in any case: Steam moves only the
files that changed (the first live update here, an unchanged 0.55 MB mod,
took 10 s end to end and uploaded one 58-byte file, measured 2026-09-19),
but it does stamp a new update on the item's page.
How it reaches Steam, and why from another process. There is no HTTP
route for Workshop content, and Publisher has no command line -- measured
2026-09-19, its binaries carry the strings of a WPF dialog and nothing else
-- so the upload goes the way Publisher's own does, ISteamUGC through the
running Steam client, using the game's own steam_api64.dll, found beside
the game the way FileBank is found beside the tools. No SDK is shipped or
needed: the DLL exports the flat C API, and steamugc.py binds the dozen
calls an upload takes with ctypes, trying interface versions newest first
so a DLL from a newer game build still answers. That module runs as its own
process per upload (python -m dayz_mcp.steamugc spec.json result.json),
because SteamAPI_Init marks the account "in game" for the process's
lifetime, a fault inside the DLL takes its process down, and a second Init
after Shutdown in one process is not something Valve supports. The app id
is the game's, the same number Publisher runs under, handed to the DLL both
ways it looks for one -- the environment and steam_appid.txt in the working
directory. Measured the same day: from a plain Python process, with the
client running, SteamAPI_Init succeeds, the app id reads back, and the two
result structs are the 24 and 16 bytes #pragma pack(push, 8) makes them.
What Steam needs from the machine. The Steam client running, logged into
the account that owns the game and the item. That is the one refusal the job
makes rather than the tool -- SteamAPI_Init is the only thing that can
answer it -- and it is the first thing the job does, before anything is
sent. An account that has not accepted the Workshop legal agreement can
still create and update, but its item stays hidden until it has; Steam says
so in the result, and the job summary repeats it with the link.
Read back. After a successful submit the job reads the item through the
public GetPublishedFileDetails listing -- no key, no login -- and the
summary says what it showed: the title, when it was last updated, and
whether that is this upload. A private item is not in that listing at all,
and the summary says that instead. workshop_status is the same read on
demand, beside what is on disk, with stale -- the newest pbo is younger
than the item's last update -- as the one fact derived from both.
Known limitations
A Workshop upload needs the Steam client, and the account it is logged into.
workshop_publishcannot log in and must not: it uses whatever session the running client holds, and the job refuses when there is none or when that account does not own the game. The public listing it reads back does not show private items, so a freshly created item reads as "not visible" until it is made public.Stale-pbo detection is mtime-based, not content-based.
mod_buildrefuses a freshly built pbo that is older than its sources -- the usual cause is a running server still holding the old file open, so packing silently produced nothing. Butgit checkoutchanges a file's modification time without changing its content, so a perfectly good pbo built right after switching branches can trip this check too. Ifmod_buildreports "stale pbo" immediately after a branch switch, this is the likely reason, not a real packing failure -- rebuild and it will pass. A mature tool in this space moved to a content hash for exactly this reason; that is future work here, not done in this phase (seepacker.py,pack_one).A mod source folder is packed whole.
mod_buildrefuses to pack a mod whose source directory contains anything matchingbuild.exclude(the seven-pattern default is listed above) rather than silently shipping it inside the published pbo. It refuses regardless ofbuild.excludewhen the source contains this server's own artifacts -- the signing keys, either half of the profile, the job store, the mod's previous build -- because packing those publishes the private signing key, and no project should have to configure that away. By default it does not stage a filtered copy first: a copy is always newer than the sources, which would permanently disable the stale-pbo check above if that check measured the copy.build.stage = trueopts into copying anyway -- safe only because the stale-pbo comparison always measures the original source tree, never the copy. This is the layout a mod whose source is the repository root needs (it always contains at least.git).A verdict judges the whole log, not just your mod's lines.
log_verdictreads the log of the stand it is pointed at, so a stand shared with other mods counts their warnings against yourexpect.max_warningsbudget and their errors as reasons. Two projects sharing onemachine.stand_rootwill see each other's baseline. Either give each project its own stand, or set the budget knowing what else is loaded. A project-scoped filter, symmetric withexpect.error_regex, is the obvious refinement and is not implemented.expect.noisecannot rescue a line that already counts as an error. Classification is orderedforbid→ crash → error → noise → warning, so a line containingERRORorFATAL(or one of yourforbidstrings) is decided before noise is consulted. That order is deliberate -- noise matching first would let an innocuous substring swallow a fatal line -- but it meansnoisecan only suppress warnings and ordinary lines, never demote an error-level one.client_verdictis an errors-and-crashes verdict, not a readiness one.[expect]describes the server's log: its ready line and counters are printed by a mod's server-side init, andmax_warningsis a budget counted over that same log. A client.RPTcontains none of it, so those three keys are deliberately not applied here and the answer lists them innot_applied.forbid,error_regexandnoiseare about the text of a log line and still apply. There is no client-side ready line to declare; whether the client got in is answered by the player countclient_startwaits on, not by its log.The client tools join a stand this session did not start;
client_chatcannot.client_startwill happily connect to whatever is already on the port, and says whose it is. But chat is delivered server-side, through the same channel as theworld_*tools, and that channel acts only on a server this session started — so on a borrowed stand everything exceptclient_chatworks. The refusal names the pids holding the port rather than suggesting aserver_startthat would refuse them.Chat is not reachable from the gamepad, and confirming a menu is not either. The game binds its chat line to Enter and nothing else, and there is no on-screen keyboard, so text is either a bridge message (
client_chat, free) or real keystrokes (client_type, costs the foreground).client_type("", submit=True)sends Enter alone, which is how the chat line is opened — and, on the evidence above, the only confirm the tool set has.Every knowledge search pays a walk of the project tree. That walk is how the project layer's staleness is measured on every answer, which is the one property the index exists for. It costs 3.0 ms on a real 41-source mod (whose tree holds about 1800 entries) and 21 ms on a tree of 2810 files. Caching it for a second or two would remove the cost and restore exactly the window of silence the design refuses; if it ever becomes too expensive, that trade has to be made deliberately, not by accident.
A build always goes through a job, and the job costs more than a small build does. Turnaround measured at 70–95 ms against a 6 ms project rebuild:
job_waitpolls at 100 ms. The single shape is deliberate — a caller must not have to know which build blocks — and nothing forces you to wait, because the next search measures the layer itself.The dependency layer is measured against the profile as it is now. Add a mod to
mods.requiredand its archives arrive asadded; remove one and its archives read asmissing. That is the requirement (the declared set is part of what the layer is built from) but it looks like the index went stale when what actually changed was the profile.corealways includes the game's configs, and that is most of its cost. 70 s with them against about 4 s for the scripts alone. There is no switch: without the configs "is there an item class called X" cannot be answered, and a second axis would make the staleness measurement ambiguous — the walk would not know whether to expect theAddonsarchives.knowledge_showanswers nearest-layer-first. For a class a dependency reopens withmodded class, the mod's declaration comes before the game's. That is the right order and a surprising one; passlayer='core'for the game's own.Conditional compilation is indexed, not resolved. 4.9% of the game's script lines sit inside
#ifdef, including about a hundred class declarations. This server drives server, client and diagnostic builds, so there is no single correct set of defines: everything is indexed and the guard is recorded on the declaration. A name can therefore be reported that a particular build excludes — the alternative, filtering by one guess at the defines, would deny the existence of methods that are in the running build.C12's fingerprint carries the file's size, and
binarize's size is not stable. Rebuilding a model that nobody edited produced an artifact one byte larger than the shipped one, with the same kind, the same LOD count and the same fifty strings — and a different digest, because the size is part of it. The step is not always that small: of five rebuilds in a row of another unchanged model, four landed within three bytes of each other and one came out 230 bytes larger, with the same LODs and the same strings. So C12 can warn about a rebuild that changed nothing. It warns rather than refuses for exactly this reason, and the parts it is built from are reported beside it so the comparison can be made by hand. Splitting the digest into a stable half and a size is the obvious refinement and is not done.A build that is killed outright leaves its drive letter behind.
asset_buildmaps a letter onto the model root for the run and removes it when the run is over. If the server process itself is killed in between, the letter stays until the next logon (subst Z: /Dremoves it by hand). The next build takes another letter instead of borrowing that one, because a letter that already points at the same root may just as well be a build in flight in another server, and nothing tells the two apart.A partial export warns; it does not refuse. With the exporter's own default arguments a model came out carrying 2 of its 5 LODs and passing every other check. This server does not pass those arguments, so it should not happen — but an object marked as a LOD and not linked into the scene counts on one side of the comparison and not the other, which is a legitimate reason for the counts to differ, so a refusal would have false positives. Read E3.
The containment rule cannot see every wrong root. It refuses a root that does not hold the mod's prefix folder, which is the measured failure. A root that does hold a folder of that name — a repository whose own mod directory is spelled like the prefix, for instance — passes it, and what catches that case is C10 or C3/C4 one layer down. Measured: pointed at such a root, the build refused, deployed nothing and left the shipped artifact untouched, but the refusal came from the job rather than from the call.
asset_exportneeds the exporting add-on enabled in Blender, and cannot install it. Blender is launched with the machine owner's real preferences, because starting it with--factory-startuptakes the add-on away entirely. Their other add-ons are kept off the search path for the run (two of the ones installed here reach the network as they start and are blamed for crashes), which Blender reports as "Add-on not loaded" in the log — that line is this server's own doing, not a fault.A binarised config has no body to show.
knowledge_show(body=True)reads a declaration back out of the file or archive it was indexed from, but aconfig.binholds the binary form while the index holds whatCfgConvertmade of it. The answer says so instead of returning nothing.
Testing
python -m pip install -e ".[dev]"
python -m pytestThat is the hermetic suite: everything runs against fixtures, on any machine, in about eighty seconds.
Corpus tests
A second half of the suite runs the readers and the checks against REAL
artifacts — a binarized model that works in game, one built from the wrong
directory, a texture whose alpha survived conversion and one whose did not, an
unpacked scripts.pbo, a folder of installed mods. Those artifacts are not in
this repository and never will be: they are gigabytes, and most of them belong
to somebody's mod rather than to this server. The tests find them through
environment variables named after the PROPERTY under test, never after the mod
they came from, and skip when a variable is unset.
Set them once per machine: copy tests/samples.local.example.toml to
tests/samples.local.toml (git-ignored) and fill in your own paths. That file
is read by tests/conftest.py before any test module is imported — which is
when the skips are decided — and never overrides a variable the environment
already carries, so a one-off DAYZ_MCP_SAMPLE_ODOL=... pytest still wins.
The cheap half then runs by default. The expensive half — anything that
launches DayZ Tools or Blender, or indexes a whole corpus — carries the
corpus marker and stays out of the default run, because it takes minutes
rather than seconds:
python -m pytest -m corpusThe one sample a normal machine does not already have is the vanilla layout
corpus, because the game ships it packed. Unpack it once with this server's
own formula, then point DAYZ_GUI_LAYOUTS at the result:
python -c "import subprocess; from pathlib import Path; from dayz_mcp.packer import bankrev_cmd; from dayz_mcp.paths import BANKREV_REL, find_tools; subprocess.run(bankrev_cmd(Path(find_tools()) / BANKREV_REL, Path(r'<game>/dta/gui.pbo'), Path(r'<corpus>')))"<corpus>/gui/gui/layouts is then the directory to name. If you regenerate
data/layout-vocab.json at all, regenerate it from the same unpack, so the
vocabulary and the parse test are talking about one build of the game.
Install
As a Claude Code plugin
The plugin brings the server and the dayz-modding skill. In Claude Code:
/plugin marketplace add covalschi/dayz-agentic-modding-mcp
/plugin install dayz-agentic-modding@dayz-agentic-moddingThe plugin starts the server with uv run --frozen, so uv (Astral's Python package
manager) must be on PATH; the first start builds the environment from uv.lock (seconds). A
project still needs its dayz-mcp.toml and dayz-mcp.local.toml ("Why a profile"
above). The gamepad stays optional: the plugin does not install the client extra.
What the plugin runs, sends and fetches
Everything runs on your machine, under your user, and only when a tool is called.
Programs it starts: DayZ Tools (FileBank, DSSignFile, CfgConvert, BankRev, binarize, ImageToPAA), the DayZ server and client that the project's
dayz-mcp.local.tomlnames, and, only for the tools that need them, Blender and the ViGEm virtual gamepad. The game's own server and client talk to Steam as DayZ always does.Input it sends: keyboard, mouse and gamepad input to the DayZ client it started (the
client_*andui_*tools).Files it writes: the mod's packed and signed output, the stand's profiles and logs, and its own state, the knowledge index included, in the project's
.dayz-mcp/folder.Network:
workshop_publishuploads the mod's files and listing to the Steam Workshop through the Steam client you are logged into (steam_api64.dllfrom the game folder);workshop_status, andworkshop_publishwhen it reads an upload back, ask Steam's publicISteamRemoteStorage/GetPublishedFileDetailsendpoint for the item, without a key;server_modssends A2S queries (UDP) to the game server address you give it.
Nothing else leaves the machine: no telemetry, no other endpoint.
By hand
python -m pip install -e ".[dev]"Register in your MCP client:
{ "mcpServers": { "dayz": { "command": "dayz-mcp" } } }The skill on its own is the folder skills/dayz-modding: copy it, or link it, into
~/.claude/skills/.
Licence
GPL-3.0-or-later. See NOTICE.md. The skill in skills/dayz-modding has its own
licence: its text is CC BY-SA 4.0 and its code (samples, template, scripts) MIT, so a
mod built from them can carry any licence (skills/dayz-modding/LICENSE.md).
Available Tools
55 toolsasset_buildA
Build a mod's models from their MLOD sources and put them in the mod.
Returns a job_id immediately: ONE small model measured 75.6 to 78.7
seconds across four runs, so this can never be a blocking call. Wait for it
with job_wait(job_id, timeout=...) -- give it minutes, not seconds -- and
read the numbers in the job's summary and its asset-build.json artifact.
What it does, in order: run binarize with its working directory set to
the project root declared as build.project_root, judge the ARTIFACT that
came out (never the tool's exit code -- three separate broken outcomes were
measured exiting 0, one of them leaving a zero-length file), and only then
copy the models into the mod. A refused build deploys nothing and leaves
the artifact the mod already ships exactly as it was.
mod names one of build.mods; with a single declared mod it can be
omitted. source is the model directory relative to the mod's own folder
under the root (e.g. "data/models"); left out, the only directory holding
.p3d files is used and two candidates are a refusal rather than a guess.
deploy=False builds and judges without writing into the mod.
The source is the MLOD export and the ODOL is the build's output (decision
D3). Handed an ODOL, binarize dies with 0xC0000005 and leaves a
zero-length file behind, so that is refused before the process starts.
Model.cfg is never copied for you: it is what the artifact was built from, and a mismatch between the copy under the root and the copy in the mod is reported by C11 with what to do about it. Rebuilds are not byte-stable and are not expected to be -- what a rebuild is compared against is structural.
Inputs: mod, deploy, source. Returns: ok, data, hint, error
asset_checkA
Judge the models and textures a mod already ships. Builds nothing.
Needs no DayZ Tools and no build: every check reads a file that is already on the disk, because the tools that produce these files are structurally unable to report failure. Answers in milliseconds.
Twelve checks (C1-C12). Four of them refuse, and this call fails when one
does: a built model is there and is an ODOL, no reference escapes the mod,
a material was actually inlined, and nothing already binarized is offered
back to binarize. The rest warn -- dangling references, an rvmat pointing
into another mod, a transparency lost to DXT1 (C7), an animation that never
reached the artifact, a model.cfg that is not the one the artifact was
built from, and a structural fingerprint that no longer matches what the
last build deployed. Every finding says what to DO about it.
model narrows it to one file, relative to the mod's directory. Files the
packer will drop (build.exclude) are not judged: a refusal about a file
that never enters the pbo is a refusal about nothing.
A texture is judged against the PNG it came from -- beside it or at the
same place under build.project_root -- because a legitimately opaque
texture and one whose transparency was destroyed are identical in the
output alone.
Inputs: mod, model. Returns: ok, data, hint, error
asset_convertA
Convert one texture between .png and .paa, and judge the result.
Which compression ImageToPAA writes is decided by the SOURCE FILE'S NAME,
and nothing says so at the time: a name ending in _co produces DXT1,
which keeps ONE BIT of alpha. A source measured 6 distinct alpha levels
going in and 2 coming out. So this measures the source's alpha before
converting, warns before the loss and again after it (C7), and says what to
do -- rename the source to end in _ca, because the output itself cannot
be repaired once the levels are gone.
source is absolute, or relative to the repository, or relative to
build.project_root. output defaults to the same name with the other
extension, beside the source; a relative output also lands beside it.
The verdict is read off the file that was written, never off the exit code.
Inputs: source (required), output. Returns: ok, data, hint, error
asset_exportA
Export a model out of a .blend into the project's model root.
Returns a job_id; wait for it with job_wait(job_id, timeout=...). One
five-LOD model measured 2.1 s warm and about 8 s cold, but the ceiling is
minutes, because how long an export takes is a property of the model.
This is the pipeline's OPTIONAL first step. It produces the MLOD; the
binarized model that the game loads is what asset_build makes from it,
and that is a separate call on purpose -- each half has its own verdict,
and a mod whose .p3d came from somewhere else skips this entirely.
The project root declared as build.project_root is pushed into the
exporting add-on for the duration of the run, so what the add-on has stored
decides nothing. That matters: an add-on preference is remembered from
whatever was open last, and against the wrong root every texture path comes
out with the drive letter stripped and the rest kept -- valid-looking,
resolving to nothing, reported as a success.
The verdict is read off the file: it must be an MLOD, this run must be what
wrote it, all of the source's LODs must have reached it, and none of its
references may leave the mod. Measured on a real model: with the exporter's
own default arguments, 2 LODs of 5 came out as a valid MLOD with correct
paths, FINISHED, exit 0, and no mention of it in 169 lines of log.
blend is absolute, or relative to the repository, or relative to
build.project_root. source is the directory the model should land in,
relative to the mod's own folder under that root (e.g. "data/models"); with
exactly one such directory already holding models it can be omitted.
name is the file to write, defaulting to the source file's own name with
a .p3d extension.
Nothing else is touched: an export that produces nothing leaves the model that is already there exactly as it was, and Blender's user preferences are never written back.
Inputs: blend (required), mod, name, source. Returns: ok, data, hint, error
bridge_buildA
Pack the bridge mod from this repository's own sources.
The bridge is the server's own mod, not the project's: one copy serves every project, and it is built UNSIGNED -- no project's signing key is used, and the output folder is kept free of signatures and keys.
Building it does not load it. Attaching it stays a profile decision (the job summary prints the two lines to add), because the bridge is an extra pbo in the stand and a run without it has to remain possible.
Inputs: no named parameters. Returns: ok, data, hint, error
bridge_clearA
Discard whatever command is sitting in the mailbox.
The remedy for bridge_status's stale_command. Inside the game, claiming
a command IS deleting the file, so a command sent while the stand was down,
or before the bridge was wired into -serverMod, is never claimed and never
expires on its own: it blocks every later send until something removes it.
Two things on this side do -- this tool, and server_start, which clears the
transport before every boot. A stand booted outside these tools would run
the command instead.
Its own tool, and never a side effect of asking for status: throwing away a
queued command is a decision, and bridge_status reporting the wedge must
not be the thing that silently resolves it.
Refuses when anything suggests the bridge is alive, because a running mod
could claim that command at any moment and destroying live in-flight work
is worse than leaving the wedge. FIRST on the plain fact that a server this
session started is running -- whatever its bridge is or is not publishing,
which matters most for a mod that has not started writing state yet -- and
that refusal costs no probe at all. Otherwise the channel probes for
probe_window
seconds and refuses on a tick that moved, on a world that restarted, AND on
a readable first sample followed by an unreadable second one -- that last
is proof something was alive moments ago, which a downed stand never
produces. force=True overrides all of it, and what it overrode is
reported either way.
Inputs: force, probe_window. Returns: ok, data, hint, error
bridge_statusA
Is the bridge inside the running game still ticking?
Blocks for up to window seconds (default 2, clamped to STATUS_WINDOW_MAX)
because that is what it costs to see a tick MOVE. window=0 returns at
once and then usually cannot tell -- it reports "unknown", never "frozen".
A stand with no state file at all now costs the FULL window too (measured: 2.07s at the default, 10.05s at the cap), because the reader retries to the deadline rather than giving up on the first miss. If the question is only "is the bridge publishing anything yet" -- the usual one while wiring it up -- pass window=0 and get the same answer in a tenth of a second; a window buys movement, and nothing else.
The answers, told apart in this order:
no_server nothing is running, so there is nothing to ask. Checked
FIRST and on the process, not on the file: the state
file outlives the server that wrote it, and reading a
leftover snapshot as a live bridge is precisely the lie
this ordering prevents.
stale_command the same, but with a command still sitting in the
mailbox. Its own answer because the remedy is its own:
the command does not expire, it blocks every send, and
a stand booted OUTSIDE these tools would pick it up.
(server_start clears the transport before every boot,
so a server started through these tools will not.)
no_state_file / the server is up but nothing readable came back. Four
outdated_bridge / fixes, so four answers: the mod is not loaded; the mod
invalid_state / predates this server's protocol (no session_id at all);
unreadable_state the document is valid JSON but a named field is wrong
(the answer says which field, what was expected and
what was seen, and it is checked twice a publish
interval apart so a mangled write is never reported as
a schema bug); or it does not parse at all.
alive / restarted a comparison was made. alive means the tick moved;
/ frozen / restarted means a new world came up mid-sample (also
unknown alive, and NOT frozen); frozen means the same world
was seen twice without moving; unknown means no
comparison could be made. Only the first two return ok.
Inputs: window. Returns: ok, data, hint, error
client_chatA
Put a line in the connected player's chat. FOCUS IS NOT NEEDED.
Said plainly because the general rule about typing does not apply here. Chat is a SERVER-SIDE MESSAGE: the engine hands the mod a call that delivers text to a player, so the bridge sends it as data -- no keyboard, no window, no foreground, and nothing taken away from whoever is at the machine. It is also more trustworthy than real typing, because the command carries an id and comes back with the mod's own confirmation instead of "typed it and hoped".
This is NOT the tool for a mod's own input field -- a PDA, a terminal, a
form. Those exist only on the client and are filled by client_type, which
does need the foreground.
The line goes to EVERY connected player, and the answer says how many got it: the engine's call names one recipient, so a verb that quietly took the first player would put the line on one screen and leave it missing from the one the caller was watching.
color is one of colorStatusChannel (the default), colorAction,
colorFriendly or colorImportant. Anything else is refused BY THE MOD rather
than passed on, because the client turns a colour class it does not know
into plain white and says nothing about it. Long lines are refused too,
rather than cut somewhere the caller cannot see.
What success promises is that the engine accepted the call for each named recipient -- not that the line was visible. A client drops whole chat channels according to the player's own profile options, so an accepted line that nobody can see is a client setting, not a fault in the bridge.
Requires the bridge to be loaded and ticking, like every other world command, and requires the stand to be running a bridge build that knows the verb; if it is not, the mod says so and the hint says what to do.
Inputs: text (required), color, timeout. Returns: ok, data, hint, error
client_compile_checkA
Compile the CLIENT half of the scripts and judge the result. Returns a job id.
A server boot never compiles anything behind the client-only guard, so a
broken menu, a broken widget or a broken client-side action passes every
server check and then breaks in front of a player. This runs the diagnostic
client for wait_seconds, stops it, and judges what it wrote -- and the
verdict does not accept a clean log on its own: the game's own "Module:
Mission" line has to appear, otherwise "no errors" only means "not that far
yet" and the job says so.
The client runs against a THROWAWAY -profiles directory inside this job's artifacts, so it never reads or writes the test stand. It also joins nothing: this is a compile pass, not a session. For the live client that connects to the stand -- and for looking at it, acting through it, and judging its .RPT -- the tools are client_start and its siblings, and log_verdict(source="client") reads THIS job's log while client_verdict reads the live client's.
extra_mods appends to the -mod list for this run only, for checking that
a mod still compiles alongside another one.
Inputs: extra_mods, wait_seconds. Returns: ok, data, hint, error
client_lookA
Turn the camera with the right stick. NO FOCUS NEEDED.
Same units and the same guarantees as client_move: x positive right,
y positive up, clamped to [-1, 1], released on every exit path.
Inputs: x (required), y (required), seconds (required). Returns: ok, data, hint, error
client_moveA
Walk the character with the left stick. NO FOCUS NEEDED.
x is positive to the right, y positive forward, both in [-1, 1] and
clamped (and reported as clamped) beyond that. client_move(0, 1, 6) is the
measured 24 m walk. The stick is back at rest when this returns, on every
path including a failure -- a stick left engaged is a character running
forever with nobody watching.
This is the only tract that moves the character at all: keyboard emulation was measured at 0 m over 25 s with the foreground verified. It is also the only one that is ANALOG, so "walking rather than sprinting" is testable here and nowhere else.
The first call attaches a virtual controller to this machine, which the
game can see -- DayZ switches its on-screen hints to controller mode -- and
the answer says so. client_stop unplugs it.
Inputs: x (required), y (required), seconds (required). Returns: ok, data, hint, error
client_pressA
Press one gamepad button. NO FOCUS NEEDED -- this drives the INTERFACE too.
Measured with a third-party application holding the foreground the whole
time: right_shoulder moved between options tabs, back opened and closed
the inventory, b left the menu. So menus and inventory are reachable
without ever taking the screen from the person at the machine, and that is
why this module offers no window messages and no mouse: window messages
were measured to do nothing at all.
What a pad cannot do is type -- DayZ has no on-screen keyboard. Text in
chat goes through client_chat (server-side, no focus); text in a mod's own
input field goes through client_type (real input, foreground).
Buttons: a b x y back start left_shoulder right_shoulder left_thumb right_thumb dpad_up dpad_down dpad_left dpad_right.
Inputs: button (required), seconds. Returns: ok, data, hint, error
client_shotA
Capture the client's window to a PNG. NO FOCUS NEEDED.
Measured rather than assumed: the frame is live with the client at the very bottom of the z-order and live when it is in front. The one state it cannot survive is MINIMIZED -- the client area collapses to 0x0 and there is nothing to copy -- and that is refused with a hint saying to restore the window, not reported as an empty picture.
lit_fraction in the answer is the honest half: a black capture is exactly
the failure that otherwise reports success, so the fraction of non-black
pixels comes back with every shot. A dark frame is never failed -- night is
dark -- but a caller reading 0.0 knows the eyes were shut.
With no path the file lands in this project's own .dayz-mcp/shots.
Inputs: path. Returns: ok, data, hint, error
client_startA
Start the game client and connect it to the test stand. Returns a job id.
Three things worth knowing before calling, all of them observable:
IT LAUNCHES WINDOWED, always. A fullscreen D3D window does not yield the foreground -- an attempt to cover one simply hung -- so a fullscreen client can be neither typed into nor left behind while the owner works. The window size itself is the client profile's business (DayZ.cfg), not this server's.
IT REFUSES WHEN THERE IS NO STAND TO JOIN. The client connects, it does not listen, so there is no port of its own to pre-flight; what there must be is something already on the stand's port. Without that the client sits at the server browser forever and every later tool answers about a client that never joined anything.
IT READS pauseMode AND WARNS. That setting (GAME -> UPDATE IN BACKGROUND,
stored in the client's own profile) is why the screenshot is a live frame
and why the gamepad moves the character while another window has the
foreground. At another value both stop working with nothing said anywhere,
so the value is reported on every start and a warning is attached when it is
not the one measured here. It is never rewritten: it belongs to the person
who owns this machine and is changed from inside the game.
READINESS IS THE PLAYER COUNT IN THE BRIDGE STATE, NOT A TIMER. Connecting
took about 50 s when it was measured and it varies; a timer would call a
still-loading client ready and a never-connecting client a success. The job
finishes when the bridge publishes players >= 1, and if that never
happens the failure says which of the two things went wrong -- the bridge
was never readable (the signal was unavailable) or it was readable and
nobody joined (the client itself did not get in).
extra_args appends launch arguments after the fixed ones, an explicit
one-run opt-in. Arguments this tool computes (-connect, -port, -mod,
-profiles, -window, -nolauncher) are refused rather than allowed to
displace its own.
Inputs: timeout, extra_args. Returns: ok, data, hint, error
client_statusD
Everything about the client that decides whether the other tools can work.
Four questions in one answer, because each of them has silently broken a run before: is the process alive; is its window in a state the eyes can capture (a MINIMIZED window cannot be captured at all -- its client area is 0x0); is UPDATE IN BACKGROUND still at the value that keeps the frame live and the gamepad effective while unfocused; and has the client actually joined the stand (the player count the bridge publishes).
Inputs: no named parameters. Returns: ok, data, hint, error
client_stopA
Stop the client this session started, and unplug the virtual controller.
The pad goes with it deliberately. Nothing else in this tool set closes it, and a controller left attached is visible inside any game running on this machine -- DayZ switches its on-screen hints to controller mode as soon as one appears. Stopping the client is the end of the input session, so it is where the device is given back.
Takes no pid on purpose: the client runs the same executable as the server, so a pid argument could not be checked against the image the way server_stop's is, and "stop this pid" would become a general process killer.
Inputs: no named parameters. Returns: ok, data, hint, error
client_typeA
Type into a CLIENT-SIDE INPUT FIELD -- a mod's PDA, terminal or form. REQUIRES THE ACTIVE WINDOW, and takes it.
This is not chat. Chat is a server-side message and client_chat delivers
it with no focus at all; use that unless the point of the test IS the
typing. What this tool is for is the case the bridge cannot reach: a field
that exists only on the client, which can only be filled the way a person
fills it -- the input line opening, the characters landing in the field,
the keyboard layout behaving.
So it brings the client window to the front and VERIFIES that it got there before sending a single keystroke. If Windows refuses -- which it does to a background process -- nothing is typed and the refusal says so, rather than keystrokes going into whatever window the person at the machine is using. That accident happened here once and is why the verification is not optional.
A successful call reports that the foreground was taken, because that is a side effect on a human, not an implementation detail.
The text is typed as US-layout scancodes, because the client starts on another layout and a virtual-key code would produce different characters. Anything with no scancode is refused by name BEFORE the screen is taken -- an underscore that arrived as a hyphen once failed a run as if the mod were at fault.
submit presses Enter afterwards, so filling a field and confirming it does
not need a second tool that takes the foreground all over again. With
submit=True and EMPTY text it sends Enter and nothing else, which is the
only way anything in this tool set can confirm or open something. Measured
on a live client: the game binds its chat line to Enter alone, and the
virtual gamepad's A button -- the obvious candidate for a confirm -- moved
nothing in the pause menu at 0.1 s or 0.5 s, while B (back) dismissed it at
the default. So dismissing is a gamepad job and confirming is this one.
Empty text WITHOUT submit is still refused: it would take the foreground to
do nothing at all.
Inputs: text (required), submit. Returns: ok, data, hint, error
client_verdictA
Judge the running (or last) client by its own .RPT.
The .RPT and not a script log, and that is a finding rather than a preference: in the Steam DIAG build the script log receivers are compiled out, so -logToFile / -logToScript / -logToRpt inject nothing and no script_*.log is produced for a client at all. Whatever the mod printed to the RPT is here; whatever it wrote only to the script log does not exist, and this says so instead of returning a clean verdict over an empty file.
Not to be confused with log_verdict(source="client"), which judges the
throwaway profile a client_compile_check job produced. This one judges
the LIVE client -- the one client_start launched, against the stand.
since ties the verdict to a run (pass the value client_start returned):
a report last modified before it belongs to an earlier client and is
refused as a reason rather than silently judged.
Inputs: since. Returns: ok, data, hint, error
job_artifactsD
Inputs: job_id (required). Returns: ok, data, hint, error
job_statusD
Inputs: job_id (required). Returns: ok, data, hint, error
job_waitA
Wait for a job to finish, or until timeout seconds pass.
timeout is clamped to at most MAX_WAIT_SECONDS (600s) regardless of what
is requested.
Inputs: job_id (required), timeout. Returns: ok, data, hint, error
knowledge_buildA
Build or refresh a knowledge layer. Returns a job_id immediately.
Layers, and how often each one needs this:
project the mod's own sources, read where they lie. Goes stale on every edit -- rebuild it whenever you want to ask about code you just wrote. Measured: 0.11 s for 41 files. deps the archives of the mods this project declares as dependencies, read without unpacking them. Stale when a dependency is updated or the declared set changes. Measured: 150 s for 36 mods. core the game itself: scripts.pbo for the API, Addons/*.pbo for the item classes. Stale when the game updates. Measured: 3.9 s for the scripts alone, 69 s with the configs. all (the default) every layer that applies to this project. Layers that cannot be built here -- no game installed, no dependencies declared -- are named with the reason instead of failing the call.
Nothing here blocks: wait with job_wait(job_id, timeout=...) and read the
per-layer numbers in the job's summary and its knowledge-build.json
artifact.
only=[path, ...] is the fast route for the project layer when you already
know what changed. It re-reads exactly those files and skips the directory
walk entirely -- and it therefore does NOT notice a file created or deleted
anywhere else. A named path that no longer exists is dropped from the
index, so a delete you name is handled; one you do not name is not. Without
only, every build is incremental anyway: unchanged sources are skipped by
size and modification time, and full=True forces the whole layer to be
re-read.
Inputs: full, only, layer. Returns: ok, data, hint, error
knowledge_callersA
Who CALLS this -- every place a method is invoked or a class is built.
A different question from knowledge_overrides, and one no search over
declarations can answer: an override is a declaration, a call is not.
This is the question asked before changing a signature, before removing
something, and when working out how a piece of the game is actually used.
kind narrows to call or new. owner narrows to calls made from one
class. Each hit names the class and method it was made from, the file and
line, and the layer -- with that layer's age, like every other answer
here.
What it does NOT see, so that an empty answer is not read as proof: a call
reached through a variable whose type the index does not track is recorded
under the method's own name, not the variable's, and new array<string>()
has no identifier before its parenthesis to record at all.
Inputs: name (required), kind, layer, limit, +1 more. Returns: ok, data, hint, error
knowledge_findA
Find declarations by name: classes, methods, constants, enums, configs.
Matching is case-insensitive and exact unless prefix=True. Answers come
nearest-layer-first -- the project's own declaration before its
dependencies', and those before the game's.
kind separates two namespaces that share a name space:
'class' a class declared in Enforce Script 'method' a function, with its owning class and full signature 'constant' / 'enum' what flags like ECE_* are found through 'config' a class declared in a config.cpp or a binarised config.bin -- this is how you ask "does the game have an item class called X". Kept apart because the game alone holds 88 102 of them against 43 595 script declarations, and mixed together they bury every script answer.
Every answer names the layers it used and how old each one is -- and an answer with no results names every layer it searched, because "not found" is worth exactly as much as the layers behind it are current. The project layer's staleness is measured on every call, so an answer taken from an index built before your last edit says so instead of describing code that no longer exists. A search that finds nothing while a layer that could have held it was never built is refused, not answered: "not found" and "not looked" are different facts.
Narrowing has the same trap one level down, so an empty narrowed answer
checks whether the name exists outside the narrowing and says where in
elsewhere -- asking for kind='class' about a name the game declares
only in a config gets a true "no" that reads as "the game has no such
class".
Every search runs under a hard time ceiling and a result limit; neither can be removed.
Inputs: name (required), kind, layer, limit, +2 more. Returns: ok, data, hint, error
knowledge_overridesA
Who overrides this class or this method -- the question a text sweep answers worst.
For a method: every declaration of it marked override, and every one
declared inside a modded class, which routinely replaces a method without
writing override at all. The original declaration is not an answer.
For a class: everyone who extends it, and every modded class that reopens
it.
Both readings are answered at once, because a caller asking "who overrides X" usually does not yet know which X it is. The answer names the layer each hit came from and how old that layer is; a search over a layer that was never built is refused rather than answered emptily.
Inputs: name (required), layer, limit, owner. Returns: ok, data, hint, error
knowledge_scopeA
Declare, inspect or clear the active mod set.
With no arguments it reports the set in force and every mod folder the dependency layer holds, so a caller can see what there is to choose from.
mods=[...] narrows every knowledge answer to those mod folders. The game
and the project's own code always answer -- the game is the substrate a
DayZ mod is written against, and the project layer is the code being
written, so narrowing either would report "no such class" about code you
are looking at.
Nothing is hidden by the narrowing. A search whose answer lies in a mod outside the set does not come back empty: it comes back naming the mod that holds it, and it comes back as a refusal, so it cannot be read as "no such thing". That is the whole point of the feature -- an invisible narrowing is the same silent lie as an answer from a stale layer.
source is free text saying where the set came from ("the server at
clear=True returns the index to answering from every mod it holds. An
empty mods=[] is refused rather than read as "narrow to nothing": those
are different requests and only one of them is ever meant.
The set is stored beside the index and survives a restart. It is NOT
changed by server_mods, which only proposes one.
Inputs: mods, note, clear, source. Returns: ok, data, hint, error
knowledge_showA
Everything the index holds about one declaration.
Where knowledge_find lists matches, this expands them: the full
signature, the file and line, what the declaration inherits from all the
way up, and -- for a class -- what is declared inside it.
body=True reads the declaration back out of the source it was indexed
from, up to max_lines lines, including out of an archive that was never
unpacked. A binarised config has no readable source to return and says so
rather than returning nothing.
Matches come nearest-layer-first, so a class a dependency reopens with
modded class is shown before the game's own declaration of it: pass
layer='core' to ask the game specifically.
Like every answer here, it names the layer each declaration came from and how old that layer is -- and when it finds nothing, every layer it looked in.
Inputs: name (required), body, kind, layer, +3 more. Returns: ok, data, hint, error
knowledge_statusA
What each knowledge layer holds, how old it is, and whether it still matches what is on disk.
Staleness is a measurement, never a guess: each layer records the size and modification time of every source it read, and this compares them against the files as they are now. A layer reports what changed, what appeared and what is gone -- so "the project was edited" is a different fact from "the game was updated", which is the whole reason there are three layers.
Two counts that must not be confused, and both are here: sources is
everything the build walked, and empty_sources is how many of those gave
no declarations at all -- an archive that could not be read, or a file that
genuinely declares nothing. Which of the two is in last_build: skipped
is what the LAST build could not read, and outstanding is every source
the layer still holds nothing from, including ones an earlier build found.
The second exists because the first is correctly empty after an incremental
build that re-read nothing, which left empty_sources standing with no
explanation at all.
Inputs: no named parameters. Returns: ok, data, hint, error
log_tailD
Inputs: n, source, pattern. Returns: ok, data, hint, error
log_verdictA
Judge the newest log for source.
since ties the verdict to a specific run (typically the value server_start
returned): a log last modified before since cannot belong to the run being
judged -- it is a leftover from an earlier boot (possibly one still holding the
file open on Windows) -- so it is refused as a reason, not silently judged.
Inputs: since, source. Returns: ok, data, hint, error
mod_buildA
Pack this project's mods. Returns a job_id.
Runs mod_lint first and refuses on what it refuses. That check costs
milliseconds and the boot it replaces costs a minute -- and two of the
defects it names would not appear in that boot's log at all, because a
modded class that modifies nothing loads and reports success.
skip_lint=True packs anyway. It exists because a refusal that cannot be
overridden is a refusal that gets worked around by not using the tool.
Inputs: skip_lint. Returns: ok, data, hint, error
mod_lintA
Check a mod's Enforce Script without packing or booting anything.
mod limits the check to one of the project's mods; empty checks them
all. strict makes warnings count as failure too -- off by default,
because a warning is something to read, not something to stop for.
Refusals: a class that extends itself (modded class X extends X loads
and applies nothing), an exception statement (try/catch/finally are
not Enforce), and a modded class whose target nothing declares.
Warnings: a statement continued onto the next line with a leading +,
which Enforce drops silently.
The modded class check needs the knowledge index, and says so when the
index cannot answer: an unbuilt layer makes it warn rather than accuse,
because "no such class" and "I have not read the game yet" are different
answers and only one of them is the mod's fault.
Inputs: mod, strict. Returns: ok, data, hint, error
project_openD
Inputs: path (required). Returns: ok, data, hint, error
project_statusD
Inputs: no named parameters. Returns: ok, data, hint, error
server_modsA
Ask a running server which mods it runs, and PROPOSE an active set.
address is "host" or "host:port"; query_port overrides an embedded one.
It must be the QUERY port, not the game port. The game port never
answers a Source query -- measured on six live servers, silent on all six,
while the query port answered on all six. The two are not related by a
fixed offset (252 distinct offsets in a live sample): a server browser
shows it, and for a local stand it is steamQueryPort in the server config.
The answer is three buckets, matched by Workshop id and never by name:
matched the server runs it and it is installed here on_server_not_installed the server runs it and this machine has not installed_not_on_server installed here, the server does not run it
Nothing is applied. proposed_scope and apply are a suggestion and
the exact call that would take it; running that call is a separate,
deliberate act. A query that silently rescoped the index would make a
mismatch an invisible action rather than something the caller read.
Three things this cannot see, and it says so in notes rather than letting
the answer read as complete: server-only mods (reasoned, not tested), the
BUILD of a mod behind an id, and mods downloaded but not linked into the
modpack.
Inputs: address, timeout, query_port. Returns: ok, data, hint, error
server_signaturesA
Read -- or deliberately change -- the stand's signature policy.
With no argument this only reports. With value it writes verifySignatures
into the config the profile names as this stand's, and reads it back.
WHY THIS IS ITS OWN TOOL. verifySignatures = 2 makes a stand refuse every
client with code 118 and "missing dtain.pbo" -- a vanilla file name that
says nothing about signatures, and that has already cost one session a long
hunt through byte-identical files. On a local stand the honest fix is
usually to turn the check off rather than to sign a throwaway bridge mod,
and doing that by hand in somebody's config is worse than doing it here,
where the previous value is reported, the file is read back, and nothing
else in it is touched.
WHAT KEEPS IT SAFE. It edits exactly one file: machine.stand_root /
machine.config, the stand this project already boots, and it refuses a
path that resolves outside that root. It refuses while a server is running
against that config, because a change the running server will never read is
a success message about nothing. And it says which way the setting moved:
0 is off, and the answer says so in words rather than leaving a number.
Inputs: value. Returns: ok, data, hint, error
server_startA
Start the test server and wait for it to be ready. Returns a job id.
Two things worth knowing before calling, both observable:
It CLEARS THE BRIDGE TRANSPORT first -- the command mailbox and the state
file in the -profiles directory are removed before the server is spawned, so
no world ever starts against a command or a state document left by an
earlier one. Script logs are deliberately left alone; see
clear_bridge_transport for why the two are treated differently. A file that
could not be removed is reported in bridge_transport_left and on the job,
and never fails the boot.
It REFUSES if the game port is already held, naming the pids holding it. A stand is shared -- one machine, one port, one profile directory -- and booting into a held port produces a server that dies during world load with nothing in its own log to say why. If the holder is a server this session started, the hint says to stop it with server_stop. If it is anyone else's, the refusal identifies it (pid and -mod= tail) and offers stopping it as the caller's own act -- the owner authorised stopping a neighbouring stand that blocks a live run -- but this tool never auto-stops a process it did not start.
extra_args appends launch arguments after the fixed ones -- an explicit
one-run opt-in, the same pattern as attaching the bridge, not profile
surgery. A list of strings, never one string to re-split. Arguments the
tool itself owns (-config, -profiles, -port, -mod, -serverMod) are refused:
the profile is where those are decided. The extras are recorded in the boot
job's summary, so a later reader can see the boot was non-standard. The
known use is the engine's action log (-doScriptLogs=1 -logToFile=1),
which writes to scriptExt.log -- a file log_verdict never reads, so these
flags cannot poison a verdict.
Readiness has two independent signals, and the summary always names which
one answered. expect.ready_line appearing in a log written by THIS run
says the MOD finished loading. The game port being bound by the pid we
spawned says the ENGINE is up and listening -- which needs neither a mod nor
a declared line, and is the readiness verdict for a project that declares
none (measured on a real stand: bound 16.9s after spawn). With a ready line
declared it remains the verdict, since a bound port cannot say a mod loaded;
the port is then what tells "the boot failed" apart from "the server is
listening and it is the mod's line that never appeared".
Inputs: timeout, extra_args. Returns: ok, data, hint, error
server_statusA
A quick health read: is the process alive, and is its log actually growing.
A hung boot and a slow one both look "alive, no ready line yet" from the
outside -- this project has hit a genuine post-compile server hang before --
so this samples the newest log's size twice, pulse_seconds apart, and also
reports how long it has been since the log last changed at all.
Inputs: pulse_seconds. Returns: ok, data, hint, error
server_stopA
Stop a server this session is responsible for.
With no pid, stops the session's own currently tracked server (the
original behaviour). With pid, stops that specific process instead --
but only if this session started it at some point, or it was reported as
orphaned_server_pid by project_open after a project switch (see
session.known_pid). Any other pid is refused: this is the only way an
orphaned server can be reached at all, and it must not become a general
process killer.
Either way, the pid is checked against the recorded image name before it
is handed to stop() (which calls taskkill): a recycled Windows pid can
belong to an unrelated process by the time this runs, and killing that
process instead would be a worse outcome than the stale bookkeeping this
guards against.
Inputs: pid. Returns: ok, data, hint, error
ui_clickA
Press a widget, by path, having checked it is still the one meant.
expect_name and expect_class are how a path stops being a loaded gun. A
tree walked a minute ago is not the tree in front of the mouse now, and
pressing "whatever is at 0.3.1 today" is how an automated run presses the
wrong button and reports success. Both are optional -- sometimes the caller
genuinely means "whatever is there" -- but then that is the caller's own
decision, taken in the open.
TWO TRACTS, and the answer always says which one was used:
via="script"delivers the click to the open menu's own handler. Works with the client in the background, no focus taken. Reaches ONLY the open scripted menu:Widgethas noGetHandler, so a HUD widget's own handler is not reachable from script at all.via="cursor"puts the real mouse on the widget's rectangle and clicks. Reaches anything the player could click -- and TAKES THE FOREGROUND, likeclient_type, because a real click goes wherever the real cursor is.
A handler that returns false is reported as what it is: the click was delivered and the menu did not act on it. That is a fact about the mod, not a failure of this tool, and the answer says so rather than inventing a verdict.
Inputs: path (required), via, root, button, +3 more. Returns: ok, data, hint, error
ui_findA
Find widgets by name, class or text, without fetching the whole tree.
name and class_name match exactly; text matches as a substring,
because a label's exact string is the one thing a caller rarely knows in
advance. At least one of the three is required -- with none of them this
would be ui_tree, and answering it as such would hide which question was
actually asked.
Filtering happens in the client, not here: sending the whole tree back so it could be filtered locally is exactly what the page limit exists to avoid.
Inputs: name, root, text, depth, +3 more. Returns: ok, data, hint, error
ui_textA
Write into an edit box, and read it back.
Only a field the player could type into may be written. A plain
TextWidget has SetText too, but writing a mod's label from outside would
change what the player sees without changing anything the mod believes -- a
lie drawn on the screen -- so it is refused rather than quietly allowed.
The value is read back out of the widget before the answer is returned:
SetText is native and returns nothing, so "it was set" would otherwise be
this tool's own claim about itself.
Inputs: path (required), text (required), root, timeout, +2 more. Returns: ok, data, hint, error
ui_treeB
The client's widget tree: what is on screen, as the engine holds it.
root is "menu" (the open scripted menu, the default) or "screen" (the
whole workspace). Each node comes back with its path, class, name,
visibility, screen rectangle, depth and -- where the engine allows it to be
read -- its text.
The answer is A PAGE: total is how many nodes the walk visited and count
is how many it listed, and truncated says when they differ. A shorter list
that did not say so would read as the whole interface.
Inputs: root, depth, limit, timeout. Returns: ok, data, hint, error
world_actionA
Run a mod's own action through the engine's gate, on the server.
action_class is the action's script class name. There is deliberately no
verb dictionary: the same word means different things in a mod depending on
context, so applicability is decided by the ACTION'S OWN Can() -- and its
refusal is a meaningful test result, not a tool failure. The distinguishable
refusals, classified in the mod before the engine is touched: the manager is
busy; the player is already acting; the player is sprinting; the action
class is unknown; and "the action's own Can() said no" -- the last one being
the answer this tool exists to produce.
target_class names the config class of the object to aim at (resolved to
the first match near the player); many actions take no target and it can be
omitted. subject optionally names a Man-derived entity class to act AS
instead of the connected player -- a diagnostic escape hatch, because a
spawned survivor owns an action manager while not being counted as a
player.
"Accepted" is not success: the engine can drop an accepted action one frame
later without clearing it. The mod therefore holds the command running
until the manager actually releases the action, and any failure path
releases it too -- otherwise that player could never act again for the rest
of the session. Expect an answer only after the action has genuinely ended;
a stuck action fails by the mod's own 20s watchdog, with the release noted
in the detail.
Inputs: action_class (required), pos, radius, subject, +2 more. Returns: ok, data, hint, error
world_deleteA
Delete every object of class_name within radius of pos (or of the
player, when pos is omitted).
The class is required: the mod will not delete everything nearby regardless of class, and the radius is clamped on its side. Players are never deleted, whatever the class filter says.
Inputs: class_name (required), pos, radius, timeout. Returns: ok, data, hint, error
world_entitiesA
WHICH objects are nearby, not how many.
world_state(class_name=...) counts; this one names them, with each
object's class, position, distance and health. An empty class_name lists
everything found rather than nothing.
The list is a page: the mod caps it at 200 entries and reports the true
total separately, so total larger than count means there is more out
there -- never a shorter list quietly standing in for the world.
distance is HORIZONTAL, because the engine's own radius test ignores
height: at the centre of Chernarus the terrain is 300 m up, so a
straight-line distance from a position written as "7500 0 7500" reads 320 m
for objects the engine returned inside a 150 m radius. A number that
contradicts the filter that produced it is worse than no number.
Players are not in it. The mod's own gather step skips them, which is what
keeps a delete of everything nearby from reaching the person standing in
it, and this tool shares that step deliberately rather than growing a
second notion of what is in the world.
Inputs: pos, limit, radius, timeout, +1 more. Returns: ok, data, hint, error
world_execA
Send an arbitrary verb through the bridge -- the debugging escape hatch, not a testing path.
This server does not know the verb, does not validate its arguments beyond
stringifying them, and does not answer for what the mod does with it; every
answer is marked non_standard to say so. Anything a mod's behaviour can
express as an ACTION should go through world_action instead, where the
mod's own Can() gives the refusal meaning.
A verb this bridge build does not know comes back as a failure listing the
verbs it does -- that is the mod answering, not this tool guessing. A
project that needs its own verb adds it to ITS OWN copy of the bridge's
dispatcher (IsKnownVerb, the routing, and a handler); this server ships
no registration machinery on purpose, because a verb the server typed and
validated would be a verb the server answers for.
The verb must be lowercase ASCII (letters, digits, underscore, up to 41 chars): the mod recovers a command's id by a raw string search when a parse fails, and the id embeds the verb -- characters outside that set can make a failure impossible to correlate, which is the silence this product exists to remove.
Inputs: verb (required), args, timeout. Returns: ok, data, hint, error
world_readyA
Wait until the bridge inside the game is actually ticking.
Call this once after server_start's boot job finishes and before the first
world command. The bridge publishes its first state during mission init but
does not start reading commands until tens of seconds AFTER the server
reports ready -- 18-38 s in the boots measured so far. A command sent in that
window
is claimed eventually and completes normally, long after the caller gave up.
Blocks, with a ceiling, because there is nothing else to do with the answer: the alternative is handing back "not yet" and having the caller poll, which is the same wait with more round trips.
Inputs: timeout. Returns: ok, data, hint, error
world_setB
Set health or quantity.
target is "player" or "hands"; left empty it defaults per what -- health
on the player, quantity on the held item -- because a single default for
both would make one of the two combinations a trap (a player has no
quantity, and empty hands have no health).
Inputs: what (required), value (required), target, timeout. Returns: ok, data, hint, error
world_spawnA
Create an item: on the ground, in the player's hands, or in their inventory.
where is "ground" (default), "hands" or "inventory". A ground spawn takes
pos as "x y z" and falls back to the player's own position when it is
omitted; with neither a position nor a player, the mod says so in words
rather than doing nothing.
Ground spawns are created with ECE_PLACE_ON_SURFACE and ECE_NOLIFETIME. Without the second flag the item lives by the lifetime in its own config and the central economy is free to remove it partway through a check -- which turns "my test item vanished" into a hunt through the mod under test. The flag is the mod's, not this tool's; it is named here because it is the reason a spawned item can be trusted to still be there a minute later.
Inputs: class_name (required), pos, where, timeout, +1 more. Returns: ok, data, hint, error
world_stateC
What the world looks like right now.
With NO class_name this costs nothing and waits for nothing: the mod republishes the player's position, health and what is in their hands every tick, so the answer is already on disk. That is deliberate -- a snapshot a caller has to pay a full command round trip for (a second to be claimed, two more of terminal dwell) would make the cheapest question the most expensive one.
With a class_name it also sends a query command to count objects of that
class within radius, because a count needs arguments only a command
carries. The count comes back in world.query_count as well as in the
detail, so it stays readable on later snapshots too -- which is how "is the
item I spawned a minute ago still there?" gets answered.
Inputs: pos, radius, timeout, class_name. Returns: ok, data, hint, error
world_teleportA
Move the player to pos, given as "x y z".
The same format world_state reports positions in, so a position read out
of a snapshot can be handed straight back. With nobody connected the mod
refuses by name -- an absent player is a distinct, stated reason, never a
silent no-op.
Inputs: pos (required), timeout. Returns: ok, data, hint, error
world_time_setA
Move the world clock.
Every field left at -1 keeps the value the world already has, read back from the engine before the change. That matters because the engine sets a date as five numbers at once: a tool that filled in the missing ones would move the date every time somebody set the hour.
Ranges are the engine's own documented ones -- month 1-12, day 1-31, hour 0-23, minute 0-59 -- and are checked in the mod, before a native call that would otherwise be handed a value it does not define behaviour for.
The answer carries the world's clock as it stands after the change, from the mod's own snapshot rather than from what was asked for.
Inputs: day, hour, year, month, +2 more. Returns: ok, data, hint, error
world_weather_setA
Move one weather phenomenon towards a value.
what is "overcast", "rain", "fog", "snowfall" or "wind". The first four
take a value between 0 and 1; wind takes a speed in metres per second.
seconds is how long the change takes (0 is immediate) and duration is
how long the value is held before the engine's own simulation may move it
again.
THIS IS A NUDGE, NOT A LOCK. The engine keeps simulating weather, so a value set here drifts afterwards -- said here and in the mod's own answer, because the alternative is a caller who sets rain, looks up two minutes later and concludes the tool did nothing.
Inputs: what (required), value (required), seconds, timeout, +1 more. Returns: ok, data, hint, error
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 55 tools
Every tool targets a distinct capability, and the descriptions actively pre-empt confusion between near-neighbors: client_verdict vs log_verdict, world_state vs world_entities, client_chat vs client_type, world_action vs world_exec, and knowledge_find vs knowledge_show are all explicitly differentiated. Even the largest families (11 world tools, 11 client tools, 7 knowledge tools) have no functional overlap.
All 55 tools follow a predictable snake_case domain_prefix_suffix pattern (knowledge_*, world_*, client_*, ui_*, asset_*, server_*, bridge_*, job_*, log_*, mod_*, project_*), so the domain of any tool is identifiable at a glance. Minor deviations: compound suffixes like world_time_set, world_weather_set, and client_compile_check, plus a mix of verb suffixes for actions and noun suffixes for read-only queries (status, state, tree, menu).
55 tools is more than double the 25-tool threshold the rubric treats as too many, and an agent must navigate families of 11 world tools, 11 client tools, and 7 knowledge tools in a single selection space. The scope is genuinely broad and every tool earns its place, but the surface could be consolidated by 30-40% (parameterized client input, merged environment setters, combined job operations) without losing capability.
The toolchain covers the full modding pipeline end-to-end: knowledge indexing, lint/build, the complete asset pipeline (export, binarize, check, convert), server lifecycle, bridge management, world manipulation, client automation, and UI interaction, with observability at every stage via jobs, logs, and verdicts. Minor gaps remain: full player inventory cannot be read (only hands), UI testing lacks wait-for-widget and scroll operations, and bridge attachment stays a manual profile edit.
Maintenance
Related MCP Connectors
Make games with AI: one MCP server that connects your agent to Unity, Unreal and Godot editors.
MCP server for building and testing AI agents with multi-model experimentation and insights.
Nifty's MCP server — exposes tasks, projects, messages, and files as tools for AI agents.
Official Duck Framework MCP server for AI-assisted development and project scaffolding.
Related MCP Servers
- AlicenseNot gradedqualityNot gradedmaintenanceAn MCP server for Arma Reforger and Enfusion engine modding that enables users to create mods, search API classes, and generate scripts through natural language. It provides a comprehensive suite of tools for scaffolding addons, generating prefabs, and building projects using the Workbench CLI.67 npm14-
- AlicenseAqualityCmaintenanceMCP server for DayZ Enforce Script that gives AI coding assistants deep knowledge of the DayZ scripting API with semantic search, code validation, class hierarchy, and reverse call graphs.81MIT
- AlicenseAqualityCmaintenanceAn MCP server that empowers AI coding agents to work effectively with Minecraft mod development, providing static analysis of decompiled source code and runtime interaction with a running Minecraft instance.3168 npm18MIT
- FlicenseNot gradedqualityDmaintenanceMCP server that helps AI agents inspect Minecraft project evidence (crash logs, mod files, datapacks) before writing development code.2-