Skip to main content
Glama

gmod-mcp

A local-first MCP server for AI-assisted Garry's Mod addon development. It plugs into Claude Code the way claude-in-chrome does: agents discover the MCP tools and iterate on their own — lint → boot → observe runtime → patch → validate. No web app, no UI.

Two halves:

  • gmod-mcp — the MCP server, TypeScript/Node, stdio transport.

  • gmod_mcp_bridge (addon/gmod_mcp_bridge) — a GLua addon exposing server state over a file transport inside GMod's DATA sandbox. The daemon and srcds share a filesystem, so no network is involved. This is not a stylistic choice: GMod's HTTP() was measured not to reach a localhost daemon from a dedicated server.

What it buys you

You talk about the addon in plain language while the model sees the actual game state — "why does this net message never fire?", "fix this error", "show me that menu". The agent lints, boots the server, reads structured Lua errors, patches, reloads and re-validates, in a loop.

Related MCP server: niua-godot-mcp

Install

pnpm install && pnpm build

# Register the server with Claude Code (project scope, committable):
node dist/index.js install
#   -> writes <repoRoot>/.mcp.json

CLI alternative: claude mcp add gmod-mcp -e GMOD_MCP_REPO=<repoRoot> -- node <abs>/dist/index.js

The daemon finds the repo root by walking up from the cwd looking for tools/lint.sh and CLAUDE.md, or via GMOD_MCP_REPO, or via .gmod-mcp/config.json.

The bridge addon must be mounted by your dedicated server. Symlink it rather than copying, so a SteamCMD validate can never overwrite it:

ln -s /path/to/gmod-mcp/addon/gmod_mcp_bridge \
      /path/to/srcds/garrysmod/addons/gmod_mcp_bridge

Transport

Server realm. The daemon writes srcds/garrysmod/data/gmod_mcp/cmd/<id>.json atomically; the addon reads it, runs it, writes res/<id>.json and deletes the command. Events (Lua errors, bridge_up) arrive as evt/<n>.json. The daemon polls res/ and evt/. No port, no token, no handshake.

One daemon per transport directory, enforced by daemon.lock (PID inside, stale locks reclaimed). The protocol consumes res/, so a second daemon reading the same directory deletes results the first one is waiting for: the command really ran, the result really was written, and the caller still times out. That is what happens the moment a second Claude Code session is opened on the same repo — measured 2026-07-25, and it cost forty minutes because the symptom accuses the game. Every bridge tool timed out while srcds was healthy and the addon mounted; reconnecting the client and restarting the server changed nothing, since the interfering state lived in a third process. Diagnose it with:

ps -eo pid,etime,args | grep gmod-mcp/dist/index.js   # more than one line is the bug

A daemon that cannot take the lock keeps its MCP tools but touches nothing: no scanner, no commands written, and every bridge call refuses with the owner's PID. health reports the same under bridge.transport. A res/ file matching no in-flight command is now left alone for a grace period rather than deleted on sight — blind cleanup is what turned coexistence into an outage — and the count of such files is reported, because on a single-daemon setup it should be zero.

Client realm. The daemon writes a cl command down the same file channel; the server addon routes it to the client over a net message; the client runs it and sends the result back, in chunks reassembled server-side into res/. No HTTP, and the client can be on any machine as long as it is connected to the server. The target is the first player, or args.player (SteamID).

Chunks are small and paced one per frame, drained one result at a time, and capped at 48 chunks per result. All three came from the same failure, met twice.

An early version pushed 60 KB chunks in a single frame, which overflowed the client-to-server reliable channel (send reliable stream overflow) and timed the client out. That failure is persistent and silent — once the channel is swamped, no net message from that client gets through, so every client tool times out and the relay looks broken when it has merely been flooded.

A relayed command the client never answers fails server-side after 20 s, under the daemon's 30 s round trip, and says so: which tool, how many chunks arrived before the transfer was discarded, and that srcds itself is fine. Half-received chunks are dropped with it — a partial reassembly is not a result. read_runtime carries the relay's state (waiting, oldest_seconds, oldest_tool, partial_transfers), because a stuck channel and an idle one used to read identically from the daemon.

Per-frame pacing alone was not enough. Measured on a real player: a full-screen capture at quality 80 came to 424 KB and 62 chunks and dropped them from the server, while a full-screen q70 capture (100 KB, 15 chunks) had gone through fine minutes earlier. Two things were wrong. Each result had its own timer, so several in flight summed on the same channel and the pacing stopped meaning anything — a caller retrying a command it believes was lost has two captures in flight, and the second is what tips it over. And nothing bounded a single result. Now one drain serialises every result, and anything over 48 chunks is refused with its size and a suggestion instead of sent. The default half-scale capture is about six chunks.

Iteration loop

edit → lint → (boot) → observe → patch → reload → validate → repeat

The daemon shells out to the host project's tools/lint.sh, start-server.sh and server-log.sh, parsing file:line: and exit codes. It also encodes the traps that cost real debugging time: the boot boundary inside an accumulating console.log, waiting for InitPostEntity before reading cvars, the queueing latency of game.ConsoleCommand, and NUL-safe log reads.

Tool catalogue

Local (daemon) — health, lint, start_server, stop_server, sync_config, read_logs, package, patch_file, restore_patch, reload_file, reload_addon, validate, run_iteration.

Server, reading — batch, read_runtime, read_players, read_entities, inspect_entity, read_hooks, read_convars, read_net_messages, read_timers, run_console_command, send_debug, run_test, run_lua (guarded, optional extension).

Server, acting (all guarded) — spawn_entity, world_edit, set_player_state, force_hook.

Client (via bridge) — read_view, client_input, read_panels, inspect_panel, capture_screen, read_console, read_client_convars.

health also asks the addon which handlers it registered and reports any the daemon declares but the game does not have. That gap used to surface only as an "unknown handler" after a full round trip, or — when a whole include was missing — not at all. It reports the transport state alongside: whether this daemon owns the directory and who holds it otherwise, what is in flight, how long since the addon last answered, and how many res/ files matched nothing of ours. Start every "nothing responds" investigation there.

capture_screen returns a real image content block. Returned as text, a base64 JPEG is billed to the model token by token and still cannot be looked at, so the "see" half of an act/see loop silently does nothing while every test passes.

Acting

The reading tools diagnose; the acting ones set up what is worth diagnosing. Server-side, spawn_entity places something, world_edit moves, freezes, heals, arms or removes it, set_player_state sets money, job, salary or RP name, and force_hook runs a gamemode hook directly.

Money goes through the r-capitalism ledger when it is loaded. That ledger holds an audited invariant — sum(balances) == issued - burned — and a raw addMoney would move a balance without an entry, leaving the drift permanently off zero. A debugging tool must not corrupt what is being debugged. Amounts are integer cents, as the rest of that server economy is.

force_hook coerces tagged arguments, because JSON cannot carry a game object: {"__ent": 3}, {"__ply": "STEAM_0:1:2"}, {"__vec": [x,y,z]}, {"__ang": [p,y,r]}.

Client-side, client_input drives the connected GMod client — movement, aim, key holds, Derma clicks, typing, chat — and read_view reports eye position, aim trace, cursor, hovered panel and keyboard focus. read_view is the cheap half of an act-then-look loop: one chunk, no image, and it answers "am I aimed at the door" without a screenshot.

client_input is bounded in Lua rather than guarded behind a confirmation. It drives a real person's machine, and a prompt clicked two hundred times is not a safety property: holds expire, durations are seconds and clamped to five, a 30s deadline resets everything, and gmod_mcp_release in the client console returns control without involving the daemon.

Batching

A bridge round trip costs about 0.4s: the addon polls at 0.25s and the daemon scans at 0.15s. Any sequence that acts and then looks pays that per gesture. batch carries up to 32 server steps in one command instead:

{ "steps": [{"tool": "read_runtime"},
            {"tool": "read_timers", "args": {"names": ["gmod_mcp_bridge_poll"]}}],
  "settleMs": 100 }

Measured against a live DarkRP server: three steps in 0.19s, versus roughly 0.75s as three separate calls.

A failing step is data, not a transport error — each step reports its own ok/data/ error, stopOnError marks the rest skipped and records abortedAt, so the caller sees the whole shape of the batch. settleMs pauses between steps, which is what makes act-then-look honest: without it a step observes the frame before the previous one landed.

Guarded tools are checked on both sides. batch is a single unguarded definition, so without the check a run_lua step would bypass the confirmation its own gate demands; the daemon resolves each step against the registry and the Lua runner repeats the check.

Any argument may instead be {"__step": 1, "get": "index"}, read from an earlier step's result. Without it, spawning something and then acting on it costs two round trips — the caller cannot know the EntIndex until the spawn has answered, which is the round trip batching exists to remove.

Steps share a server tick unless settleMs is set, and some engine effects only land at end of frame: Entity:Remove() is deferred, so a step reading the entity back still finds it valid and the agent concludes the removal failed. Set settleMs (100 is usually enough) whenever a step must observe an earlier one.

Client-realm steps are refused explicitly rather than silently dropped — a batch runs inside the server addon, and cl tools are relayed over net. So an act-then-look loop on the client is currently two round trips, not one.

All three realms have been exercised against a live DarkRP server: the server tools on rp_nycity_day at tick 33, and the client tools against a connected GMod client — read_panels returning a live VGUI tree and capture_screen returning a complete 1920x1080 JPEG. batch was proven the same way, including its failure paths: a step raising returns the real Lua error with file:line, the rest come back skipped, and a run_lua step is refused unconfirmed and executes confirmed.

Security

  • Guarded tools (run_lua) require confirm: true or membership in toolAllowlist; otherwise they are refused without executing. A guarded tool used as a batch step needs the batch itself confirmed, and both the daemon and the Lua runner enforce that independently. Every call, result, patch and executed Lua line is appended to <repoRoot>/.gmod-mcp/logs/audit.jsonl.

  • patch_file is locked to the repo root and refuses paths outside it.

  • No network listener. The server transport is files inside DATA; the MCP layer is stdio. The trust boundary is the local filesystem.

  • run_lua — arbitrary Lua execution — lives in the optional optional/gmod_mcp_runlua extension, isolated because glua-audit forbids dynamic execution and the main bridge stays lint-clean. Development only, never on a production server.

Project config — <repoRoot>/.gmod-mcp/config.json

Every key is optional (see config.example.json):

{
  "repoRoot": ".",
  "addons": ["gmod_mcp_bridge"],
  "clientWaitMs": 30000,
  "toolAllowlist": [],
  "plugins": []
}

clientWaitMs is how long a client-realm call keeps retrying while the client is absent. That realm needs a human connected, and humans crash, alt-tab and reconnect; retrying lets an agent resume when they come back instead of failing the moment they drop. Set it to 0 to fail fast. Server-realm calls ignore it — srcds does not come and go mid-session, so a failure there is a real one.

Plugins

Extend the tool set with ESM modules listed in plugins. Each module exports tools:

// my_plugin.mjs
export const tools = [
  { name: "my_tool", description: "…", realm: "local", inputSchema: {}, handler: () => ({ ok: true }) },
];

A failing plugin is reported on stderr without blocking startup.

Design notes

  • docs/2026-07-25-autonomie-client-design.md — what the client side needs before an agent can drive it without a human: name a panel instead of guessing a pixel, assert text instead of reading a JPEG, and type into a field at all. Each gap listed there blocked a real session.

Development

pnpm test        # vitest
pnpm typecheck
pnpm build

Linting the GLua addon needs the host project's tools/lint.sh (four passes) plus a local copy of GLua API definitions — see addon/gmod_mcp_bridge/.luarc.example.json.

License

MIT. See LICENSE.

Available Tools

38 tools
batchA

Runs up to 32 server tools in ONE bridge round trip instead of one per call. A round trip costs about 0.4s, so any act-then-look sequence is dominated by transport unless it is batched. Each step reports its own ok/data/error; a step failing is data, not a transport error. IMPORTANT: with settleMs 0 every step runs in the SAME server tick, and some engine effects only land at the end of a frame -- a removed entity still reads as valid. Set settleMs (100 is usually enough) whenever a step must observe what an earlier one did. Any arg value may be {"__step": 1, "get": "index"} to read a field from an earlier step's result, so spawn-then-act still costs one round trip.

Inputs: steps (required), confirm, settleMs, stopOnError.

capture_screenA

Captures the client's screen on the next frame and returns it as a viewable image. Every byte travels in 7KB chunks paced by frame, so a full-resolution capture takes seconds and dominates any act-then-look loop: the default half scale at quality 60 is 4-6x cheaper and still legible for a Derma layout. Pass region (from read_panels' screen_x/screen_y) to capture just one panel. A capture that would exceed the client's channel budget is refused with its size rather than sent: a full-screen quality-80 capture measured 424KB and timed the client out of the server. Requires an active GMod client.

Inputs: scale, region, quality.

client_inputA

Drives the connected GMod client: movement, aim, keys, Derma clicks, typing, chat. Modal -- 'world' drives movement through CreateMove, 'ui' hands input to the panel system, and the two are mutually exclusive; the action switches mode for you. Durations are SECONDS (CreateMove runs at the client's cmdrate, so tick counts are not a duration), clamped to 5s, and everything resets after 30s or on gmod_mcp_release in the client console. To fill a form use set_text (targets a field by name and fires its change notification); type sends real keystrokes and needs a target or an already-focused field. click takes a NAMED target as well as x/y and is self-sufficient -- it moves the cursor, waits for hover to settle, presses and releases, so no move_cursor is needed first. Follow with read_view or read_panel_text to see the effect.

Inputs: action (required), x, y, up, +19 more.

force_hookA

GUARDED. Runs hook.Run(name, ...) to exercise a gamemode path without reproducing the situation. JSON cannot carry an Entity, so arguments may be tagged: {"__ent": 3}, {"__ply": "STEAM_0:1:2"}, {"__vec": [x,y,z]}, {"__ang": [p,y,r]}. Requires confirm:true.

Inputs: name (required), args, confirm.

healthA

gmod-mcp daemon status: version, detected repo root, presence of the tools/ scripts, state directory. Also probes the addon (3s) and reports the transport state -- whether this daemon owns the shared directory (a second Claude Code session starts a second daemon, which breaks every bridge tool), what is in flight, and any server handler the daemon declares but the game has not registered.

Inputs: no named parameters.

inspect_entityC

Details of one entity by index: class, model, health, owner, key-values.

Inputs: index (required).

inspect_panelA

Finds a panel by NAME, class and/or displayed text, and returns its screen rectangle, its text, whether it holds keyboard focus, and the other matches. NAME is the registered vgui name (R_CharCreate, R_UI_Button, echat.textentry) and is what you want: class is the VGUI base the panel derives from, so a kit panel's class reads Label or Panel and searching by class can never find it. Off-screen panels are excluded unless onScreen:false -- a live tree measured 1408 panels of which 5 were on screen.

Inputs: name, class, index, contains, +1 more.

lintA

Runs tools/lint.sh on an addon (name or path). Returns structured findings (file, line, rule) and the exit code. Exit 0 means clean.

Inputs: addon (required).

packageA

Builds an addon's .gma through tools/package-gma.sh, linting first and refusing on failure. Output lands in dist/.

Inputs: addon (required).

patch_fileA

Replaces a file's contents (inside the repo) after backing it up. Returns a patch id for restore_patch, plus the unified diff. rationale explains the change.

Inputs: file (required), content (required), rationale (required).

read_client_convarsC

Client-side convar values. Without names, returns a common subset.

Inputs: names.

read_consoleB

CLIENT Lua errors captured since load. GMod does not expose the console buffer itself.

Inputs: no named parameters.

read_convarsB

Server convar values. Without names, returns a common subset.

Inputs: names.

read_entitiesA

Lists entities, filterable by class. Returns index, class, model and position.

Inputs: class, limit.

read_hooksB

Registered hooks (hook.GetTable), filterable by event. Returns event -> identifiers.

Inputs: event.

read_logsA

Reads the server logs. source=game (Lua errors, -condebug) or stdout (the wrapper). sinceBoot:true bounds output to the current boot. errorsOnly:true returns structured runtime findings.

Inputs: source, sinceBoot, errorsOnly.

read_net_messagesB

Registered net messages (util.AddNetworkString) and whether a net.Receive exists.

Inputs: no named parameters.

read_panelsB

Derma/VGUI panel tree: class, name, visibility, size, position both parent-relative (x/y) and absolute (screen_x/screen_y), whether mouse input is enabled, and on_screen. Filter on on_screen: visible is the panel's own flag only, so a flat tree is mostly panels belonging to closed menus. Use screen_x/screen_y to aim a click or a capture region -- x/y are relative to the parent and are (0,0) for most nested panels.

Inputs: maxDepth.

read_panel_textA

Dumps what the interface DISPLAYS, as text: name, class, screen rectangle and text content of each panel under a named root. Use this instead of capture_screen to assert a value -- a capture travels in 7KB chunks paced one per frame, and a number read off a compressed JPEG is not an assertion. Text comes from GetText, GetValue, or a .label/.text/.title field (kit buttons paint their label and answer '' to GetText). The list is depth-first with depth relative to the root, so the parent chain is recoverable from the ordering: the DTextEntry that follows the DLabel 'Prénom' is that field. capture_screen remains the tool for anything visual -- z-order, overlap, a missing glyph.

Inputs: root, index, limit, maxDepth, +2 more.

read_playersA

Lists players: name, SteamID, team/job, ping, position, health.

Inputs: no named parameters.

read_runtimeA

Snapshot of server state: map, gamemode, CurTime, player and entity counts, uptime.

Inputs: no named parameters.

read_timersA

State of named timers: exists, time left, repetitions left. GMod cannot enumerate timers, so names is required to get anything back.

Inputs: names.

read_viewA

What the client sees and is pointed at: eye position and angles, aim trace (class, index, distance, hit position), health, weapon, cursor position and visibility, the hovered panel, the panel holding keyboard focus, and the current scripted-input mode. This is the cheap half of an act-then-look loop -- one chunk, no image -- and answers 'am I aimed at the door' without a screenshot.

Inputs: no named parameters.

reload_addonA

Touches every .lua file of an addon to trigger autorefresh. Best-effort; structural changes still need a restart.

Inputs: addon (required).

reload_fileA

Triggers GMod's Lua autorefresh for a file by bumping its mtime. Best-effort: it covers edits, while new files and autoruns still need a restart.

Inputs: file (required).

restore_patchC

Reverts a patch by id, restoring the previous state.

Inputs: id (required).

run_console_commandA

Runs a server console command (game.ConsoleCommand -- queued, around 0.25s of latency).

Inputs: command (required).

run_iterationA

One full iteration: optional patch -> reload (or a restart note) -> validate. Returns the applied patch and the verdict. This is the core of the edit/observe/fix loop.

Inputs: addon (required), file, reload, content, +1 more.

run_luaA

GUARDED. Runs arbitrary Lua server-side (RunString) and returns the resulting value. Requires confirm:true. Audited.

Inputs: code (required), confirm.

run_testA

Runs a GLua test file server-side and returns {passed, failed, results}. path is relative to lua/, e.g. 'myaddon/tests/x.lua'. The file returns a table { [name] = function(t) end }.

Inputs: path (required).

send_debugA

Prints a message server-side, useful for marking the log or tracing.

Inputs: message (required).

set_player_stateA

GUARDED, DarkRP. Sets a player's money, job, salary or RP name. Money is in INTEGER CENTS (1.50$ is 150) and goes through the r-capitalism ledger when it is loaded, so the audited invariant stays intact. Fails with a named error when DarkRP is absent. Requires confirm:true.

Inputs: target (required), job, rpname, salary, +2 more.

spawn_entityA

GUARDED. Creates and spawns an entity at a position. Returns its EntIndex, which the other world tools take as a target. Requires confirm:true.

Inputs: pos (required), class (required), ang, model, +2 more.

start_serverB

Starts the dedicated server through tools/start-server.sh [map] [gamemode] [tickrate] and records the log's boot boundary. Script defaults: rp_nycity_day/darkrp/33.

Inputs: map, gamemode, tickrate.

stop_serverB

Stops the dedicated server through tools/stop-server.sh.

Inputs: no named parameters.

sync_configA

Reapplies server-config/ and (re)creates the symlinks through tools/sync-server-config.sh. check:true compares without writing (--check).

Inputs: check.

validateC

Verdict for the loop: the addon's lint plus the current boot's runtime errors. ok=true when lint is clean AND no runtime error was seen.

Inputs: addon (required).

world_editA

GUARDED. Acts on one entity or player: remove, teleport, set_ang, freeze, unfreeze, set_health, set_armor, give, strip. target is an entity index or a player's SteamID/name. Requires confirm:true.

Inputs: action (required), target (required), ang, pos, +3 more.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    A3.5/5.0

    Scored across 38 tools

    Disambiguation5/5

    Each tool targets a distinct aspect of GMod: reading entities vs inspecting vs spawning vs editing; reading panels vs inspecting vs capturing; running commands vs Lua vs tests; file patching vs linting vs validation; etc. There is no ambiguity between tools despite the large set.

    Naming Consistency4/5

    The vast majority of tools follow a clear verb_noun pattern (e.g., read_entities, run_console_command, spawn_entity). A few tools like 'batch', 'health', and 'client_input' deviate slightly, but overall the naming is predictable and consistent.

    Tool Count3/5

    With 38 tools, this is on the higher side of appropriate. While each tool serves a specific purpose in managing a GMod server, the count is above the typical 3-15 range. However, the complexity of the domain justifies the number, so it's borderline heavy rather than excessive.

    Completeness4/5

    The tool set covers most major operations: CRUD for entities and players, panel inspection and screen capture, server control, file management, linting, workflow automation (batch, iteration). Minor gaps exist (e.g., no direct tool for installing addons), but the surface is largely complete for development and debugging.

    Related MCP Connectors

    Related MCP Servers