Skip to main content

MCP Bridge for Serotonin

Drive Serotonin from any MCP-capable agent (Codex, Cursor, Cline, VSCode) without writing Lua by hand.

Repositorymcp-serotonin (drop-in: bridge.lua + server.py)
ProtocolMCP over stdio between client and Python; HTTP between Python and the in-cheat Lua bridge
Coverage30 wrapped tool calls covering exploration (instances, players, parts, bones, screen) and the full utility / memory / file / audio / ui API discovered in this documentation
Crash protectionPre-flight blacklist on the Python side, class-based property allowlist on the Lua side, post-mortem /crash_report endpoint

Architecture

┌──────────────┐  stdio   ┌──────────┐  HTTP :8765  ┌────────────┐  cheat   ┌────────┐
│ MCP client │ <─────> │ server.py│ <──────────> │ bridge.lua │ <─────> │ Roblox │
└──────────────┘ └──────────┘ └────────────┘ └────────┘
(Codex / (Python (in-cheat (game
Cursor / coordinator, long-poll Lua DataModel,
Cline / pre-flight that runs ops) memory,
custom) blacklist) entity, ui)

bridge.lua runs inside Serotonin (Scripting tab → Load) and long-polls the Python coordinator. When a tool call arrives, the Lua side executes it, serializes the result, and posts it back. An asyncio semaphore + one-at-a-time polling means parallel MCP calls cannot stack parallel evals inside Serotonin (parallel eval crashes the cheat reliably).

Why an MCP layer?

Hand-writing Lua to inspect a Roblox game gets old fast, guessing instance names, hoping entity.GetPlayers works in the current mode, hex-formatting addresses. With the bridge loaded, an MCP-capable agent can:

  • Walk Workspace, list live players with positions
  • Find the nearest target of any class within a radius
  • Read memory, project world coordinates to screen
  • Drive the cheat menu (read / write any UI widget value)
  • Stream HTTP / WebSocket data
  • Probe new APIs interactively without a full reload

The agent reads the actual game state and writes Lua that works in that specific build / mode, instead of generic templates.

Tool catalogue

Instance / world exploration

ToolDescription
serotonin_pingLiveness check
serotonin_evalRun arbitrary Lua. Instances / Vector3 / Color3 auto-serialized. Blocked patterns never reach the cheat in safe mode.
serotonin_inspectProperties + Attributes + Children for one Instance
serotonin_search_instancesWalk :GetDescendants() with Name substring + optional ClassName filter
serotonin_treeRecursive Name/ClassName dump up to N levels
serotonin_find_by_classAll descendants of a specific ClassName
serotonin_find_player_modelFind a player Model by Name
serotonin_nearestNearest instance of a class within a radius
serotonin_descendants_statsClassName histogram for a subtree
serotonin_get_scriptsAll Script / LocalScript / ModuleScript paths

Entity / parts / players

ToolDescription
serotonin_list_playersentity.GetPlayers() + cached fields
serotonin_players_fullEntity fields + live HumanoidRootPart + screen projection
serotonin_list_partsentity.GetParts() with optional radius filter
serotonin_parts_countentity.GetPartsCount()
serotonin_part_detailsFull per-part dump for one index, including 8 OBB corners
serotonin_get_bonesPosition / Size / Rotation for named bones of a player

Screen / projection

ToolDescription
serotonin_project_to_screenutility.WorldToScreen(Vector3)
serotonin_screen_infoWindow size, camera, mouse, delta time, menu state

Memory

ToolDescription
serotonin_memory_readmemory.Read(type, addr)
serotonin_memory_writememory.Write(type, addr, value)
serotonin_memory_basememory.GetBase()
serotonin_memory_scanmemory.Scan(pattern, [module]) (added)
serotonin_memory_is_validmemory.IsValid(addr) (added)

File sandbox

ToolDescription
serotonin_file_readfile.read(path)
serotonin_file_writefile.write(path, content) or file.append (boolean flag)
serotonin_file_listdirfile.listdir(path) returning {name, isDirectory, isFile, size?} records
serotonin_file_opOne-shot exists / isdir / mkdir (recursive) / delete

Audio (safe)

ToolDescription
serotonin_audio_beepaudio.Beep(freq, ms). Synchronous, blocks for ms.
serotonin_audio_stop_allaudio.StopAll()

audio.PlaySound is intentionally not wrapped because non-WAV input crashes the cheat with a native SEH exception, see audio.PlaySound.

UI (drive the cheat menu)

ToolDescription
serotonin_ui_get_valueui.GetValue(tab, container, label)
serotonin_ui_set_valueui.SetValue(tab, container, label, value) (value type must match the widget kind, see ui)

Crash protection

Some Lua expressions in Serotonin trigger native C++ exceptions that pcall cannot catch, and they kill the cheat DLL. Confirmed crashers in the current build:

  • Reading _G, game.DataModel, game.PlaceID, game.LocalPlayer.Backpack and a couple dozen other undocumented LocalPlayer fields
  • Calling Color3:ToHSV() on certain instances (see Crash triggers)
  • audio.PlaySound(""), and any non-WAV string passed to audio.PlaySound (added in this audit)
  • cheat.LoadString(...), every 2-arg invocation we tried in build version-390ba09e7e944154 raised "C++ exception" (added in this audit)
  • game.GetFFlag / game.SetFFlag (legacy flag, conservatively blacklisted)

The bridge ships with three protections layered on top of each other:

  1. Pre-flight blacklist (crash_blacklist.json) on the Python side. Every op is regex-matched before it leaves the coordinator. Blocked ops never reach the cheat.
  2. Class-based property allowlist in bridge.lua. Only documented properties are read via safe_inspect / dive. Undocumented Roblox fields are a known crash vector, Serotonin's proxy tries to resolve them via raw memory and faults on unknown offsets.
  3. /crash_report endpoint. After a cheat crash, POST the last operation that ran to the endpoint and the bridge auto-extracts a blacklist rule for the shape. Learn once, never repeat.

Updated crash_blacklist.json excerpt (this audit's additions are at the end of eval_code_blocked):

{
"version": 2,
"eval_code_blocked": [
"game\\s*\\.\\s*DataModel",
"game\\s*\\.\\s*PlaceID",
"game\\s*\\.\\s*GetFFlag",
"game\\s*\\.\\s*SetFFlag",
"\\b_G\\b",
"LocalPlayer\\s*\\.\\s*(Backpack|PlayerGui|PlayerScripts|...)",
":ToHSV\\s*\\(",
"audio\\s*\\.\\s*PlaySound\\s*\\(\\s*[\"'][^\"']{0,10}[\"']",
"audio\\.PlaySound\\s*\\(\\s*nil",
"cheat\\s*\\.\\s*[Ll]oadString\\s*\\("
]
}

Setup

Requirements

  • Windows 10 / 11 + Serotonin
  • Python 3.10 +
  • mcp and aiohttp (in requirements.txt)

Install

git clone https://github.com/DeftSolutions-dev/mcp-serotonin.git
cd mcp-serotonin
pip install -r requirements.txt

Drop bridge.lua into your Serotonin scripts folder (C:\Serotonin\scripts\bridge.lua).

Wire to your MCP client

The server speaks stdio. Most MCP clients read a JSON config that looks like:

.mcp.json
{
"mcpServers": {
"serotonin-bridge": {
"command": "python",
"args": ["C:/path/to/mcp-serotonin/server.py"],
"env": { "PYTHONUNBUFFERED": "1" }
}
}
}

Save the file where your client looks for it (.mcp.json in the project, user-level config, IDE settings).

Run

  1. Launch Roblox + Serotonin.
  2. In the Scripting tab, Load bridge.lua. You should see:
    [serotonin-bridge v2] loaded, polling http://127.0.0.1:8765
    [serotonin-bridge v2] ops: ping eval inspect safe_inspect snapshot dive live_dump class_counts list_scripts search
  3. Start your MCP client. It spawns server.py on demand.
  4. Call serotonin_ping. You should get back "pong".

If you get a timeout, check that bridge.lua is loaded and that 127.0.0.1:8765 is reachable.

HTTP control endpoints

On top of the MCP tools, server.py exposes a few HTTP routes for direct curl access:

MethodPathPurpose
POST/execRun one op ({op, args, timeout}) with pre-flight check
POST/cancelDrop every queued command. Use after a crash.
GET / POST/safe_modeGet or toggle ({enabled: true/false})
GET/blacklistFull blacklist dump
POST/blacklistPatch ({add, remove})
POST/blacklist/reloadRe-read crash_blacklist.json from disk
POST/crash_reportReport a crash, auto-extract a rule
GET/healthQueue depth
# Toggle safe mode off (dangerous)
curl -X POST http://127.0.0.1:8765/safe_mode -d '{"enabled": false}'

# Dump current blacklist
curl http://127.0.0.1:8765/blacklist | jq

# Cancel everything queued (after a crash)
curl -X POST http://127.0.0.1:8765/cancel

Tool registration pattern (extending the bridge)

Adding a new tool wrapper is two changes in server.py:

server.py
# 1. Register the tool surface
TOOLS = [
# ... existing tools ...
types.Tool(
name="serotonin_my_new_tool",
description="One-line description shown to the agent.",
inputSchema={
"type": "object",
"properties": { "x": {"type": "integer"} },
"required": ["x"],
},
),
]

# 2. Dispatch by name in _dispatch()
async def _dispatch(name: str, a: dict):
# ... existing branches ...

if name == "serotonin_my_new_tool":
code = f"return some_lua_call({int(a['x'])})"
return await bridge_call("eval", {"code": code})

raise RuntimeError(f"unknown tool: {name}")

That is the entire pattern. Build your Lua snippet, send it through bridge_call("eval", ...), and the bridge serializes the result back. For known crashers, also add a regex to crash_blacklist.json so the agent cannot trip them by accident.

Things that bite (and how this release handles them)

  • Memory types are exact. Re-verified live, only these 17 are accepted: byte, short, ushort, int, uint, int64, uint64, float, double, bool, string, ptr, pointer, vector2, vector3, color3, cframe. Every other variant (dword, qword, long, longlong, int8/16/32, uint8/16/32) raises "Invalid memory type for read: '<name>'". color3 returns multi-return r, g, b (0..255). See memory.Read.
  • game.GetService uses dot syntax, not colon. game.GetService("Players") works; game:GetService(...) errors out because the Lua game is a sandbox proxy table, not an Instance userdata.
  • Entity API returns userdata, not indices. Old docs say entity.GetPlayers() returns integers. It returns userdata. Access fields as p.Name, call bones as p:GetBonePosition("HumanoidRootPart"). See entity.
  • p.Position is often stale. Use p:GetBonePosition("HumanoidRootPart") for the live value. serotonin_players_full does this for you.
  • buffer and raknet are not bound in the current build (type(buffer) == "nil", type(raknet) == "nil"). Don't reach for them.
  • Instance:IsA compares ClassName equality, not inheritance. ws:IsA("Instance") returns false; check ClassName directly against a known set of concrete classes when you need a "is this a part" check.
  • Don't parallelize eval. Two simultaneous evals crash Serotonin. The server enforces serial execution via a semaphore.

Configuration

Environment variables:

  • SEROTONIN_HTTP_HOST (default 127.0.0.1)
  • SEROTONIN_HTTP_PORT (default 8765)
  • SEROTONIN_HTTP_ONLY=1, start only the HTTP coordinator, skip stdio MCP

bridge.lua tunables (top of the file, CFG table):

  • base_url, must match the host/port above
  • poll_interval_ms, minimum gap between polls (default 100)
  • inflight_ttl_ms, watchdog reset if http.Get callback never fires (default 12000)
  • max_depth, default serialization depth (default 3)

Timeouts are synchronized: poll hold (9s) < server default timeout (10s) < bridge inflight TTL (12s). Don't break this ordering or the watchdog will race the client and you'll get phantom resets.

License

MIT. The full source for both bridge.lua and server.py lives in the mcp-serotonin repository.