Skip to main content
Glama
mcp-vroid
by nhodges

mcp-vroid

Drive VRoid Studio from any MCP client, on Linux/Wayland. Launch the app, look at it, find widgets in the picture, click and type, set parameters, and export a .vrm — all as MCP tools.

Why

VRoid Studio has no scripting API, no CLI, no plugin surface. The only way in is the one a person uses: look at the window and move the mouse. So that is what this does — screenshot the window with grim, locate things with OCR and colour matching, and inject real pointer and keyboard events at the compositor level. The MCP client's model is the eyes; these tools are the hands.

   grim ──► PNG ──► tesseract / cv2 ──► (x, y) ──► virtual pointer / XTEST
    ▲                                                        │
    └────────────────────  screenshot again  ◄───────────────┘

Related MCP server: Persona Motion Studio

Demo

Everything below was done by an MCP client calling these tools — no human at the mouse.

Setting body parameters by typing exact values into the Parameters panel (vroid_open_tab("Body") → vroid_set_slider("Head Size", -0.15)):

The VRoid editor's Body tab, driven: parameters panel with typed values

Inside the hair editor, tuning procedural hair guides (vroid_click on the group, vroid_set_slider on Height / Interval / Twist Intensity):

The VRoid hair editor with a procedural hair group selected and its parameters open

Filling the VRM Settings modal on the way to an export (vroid_export_vrm walks the whole flow, including Wine's save dialog):

The Export as VRM screen with the VRM Settings modal, required fields being typed

Requirements

Developed and tested on Arch Linux + Hyprland, with VRoid Studio 2.14.0 (English UI) running under Steam/Proton. What is actually load-bearing:

needed for

how portable

Hyprland ≥ 0.55

window discovery, focus, workspaces, closing the screensaver — via hyprctl and its Lua dispatch API

Hyprland-specific. All of it lives in src/mcp_vroid/driver/window.py; a Sway port is a swaymsg rewrite of that one file.

grim

screenshots

any wlroots compositor (wlr-screencopy)

zwlr_virtual_pointer_unstable_v1

moving and clicking the real cursor

any wlroots compositor

Xwayland (DISPLAY)

keyboard and wheel, via X11 XTEST

any Wayland session with Xwayland

tesseract + eng traineddata

OCR — the entire locating story

portable

gcc, wayland-scanner, libwayland-client

building the pointer helper

portable

VRoid Studio via Steam/Proton (appid 1486350)

the app being driven

the Steam launch path is assumed; a native/Wine install needs the launch command changed

Python 3.11+ and uv

the server itself

portable

So: wlroots + Xwayland for the input and capture layer, Hyprland only for window management. On Arch:

sudo pacman -S grim tesseract tesseract-data-eng wayland gcc pkgconf

Quickstart

git clone https://github.com/nhodges/mcp-vroid
cd mcp-vroid
uv sync                 # virtualenv + dependencies
bash native/build.sh    # builds native/vpointer  <-- REQUIRED, not optional

native/build.sh compiles a ~150-line C client for the Wayland virtual-pointer protocol (the protocol XML is vendored under native/protocols/). Without it every pointer tool fails with native/vpointer missing; vroid_status tells you whether it is there.

Register it with Claude Code:

claude mcp add vroid -- uv run --directory /path/to/mcp-vroid mcp-vroid

…or with any client that takes an mcpServers block:

{
  "mcpServers": {
    "vroid": {
      "command": "uv",
      "args": ["run", "--directory", "/path/to/mcp-vroid", "mcp-vroid"]
    }
  }
}

Then ask your client to call vroid_status, and if it looks healthy, vroid_launch().

Clients often start servers with a sanitised environment. This server recovers XDG_RUNTIME_DIR, WAYLAND_DISPLAY, HYPRLAND_INSTANCE_SIGNATURE and DISPLAY from the runtime dir at startup (src/mcp_vroid/session_env.py), so hyprctl / grim / XTEST work anyway. vroid_status reports what it had to fill in; anything already in the environment wins.

Optional environment variables:

var

default

meaning

MCP_VROID_CAPTURES

$XDG_STATE_HOME/mcp-vroid/captures

where screenshots are written

MCP_VROID_OUT

$XDG_STATE_HOME/mcp-vroid/out

default dir for exports/saves

MCP_VROID_VPOINTER

<checkout>/native/vpointer

path to the pointer helper

MCP_VROID_MAX_IMAGE_PX

1600

longest edge of images sent to the client (0 = never downscale)

Tools

18 tools, in four groups.

Lifecycle

tool

what it does

vroid_launch(restart=false, timeout=240)

Start VRoid via Steam if needed, park it on Hyprland workspace 9, remember the workspace you were on, focus + fullscreen it. restart=true kills the running instance first — unsaved work is lost.

vroid_status()

Window present/focused/title/geometry, active workspace, capture dirs, and whether vpointer/grim/tesseract/hyprctl are available. Read-only, no OCR.

vroid_release()

Switch back to the workspace the user was on. VRoid keeps running on ws 9.

Seeing

tool

what it does

vroid_screenshot(region?, tag?, whole_screen?, full_resolution?)

Capture the window (or the whole output, for the Wine save dialog), save it, and return it as MCP image content for the client's model to look at. Reports native size and the downscale factor applied for transport.

vroid_find_text(query, region?, exact?, limit?)

Fresh capture + tesseract; returns matching word boxes and centres in image px. Pass region — full-frame OCR takes ~10 s, a panel ~2 s.

vroid_find_button(color='primary'|'disabled', label?, region?)

Finds VRoid's solid #0096FA pills by colour, because tesseract loses white-on-blue labels. A grey pill means disabled.

vroid_current_screen()

start / editor / export_vrm / hair_editor / unknown.

Acting — raw input

tool

what it does

vroid_click(x, y, space='image', button='left', double=false)

Glides the pointer in a few steps (so hover states fire) and clicks.

vroid_drag(x1, y1, x2, y2, space='image', button='left')

Press → 24-step glide → release. Right-drag orbits the camera, middle-drag pans.

vroid_scroll(dy, dx=0, x?, y?, space='image')

Wheel, as X11 buttons 4/5 (6/7 horizontal). Park the pointer over the panel you mean to scroll.

vroid_type(text, clear_first=false)

Types into the focused widget over XTEST.

vroid_key(combo, times=1)

Return, Escape, ctrl+s, ctrl+shift+s, …

Acting — flows

tool

what it does

vroid_new_character(base='Fem'|'Masc')

Start screen → Create New → base → editor.

vroid_open_tab(name)

Face / Hairstyle / Body / Outfit / Accessories / Look.

vroid_set_slider(label, value)

Scrolls the Parameters panel to the row and types an exact value into its numeric box.

vroid_set_color(label, hex)

Same, for a #RRGGBB colour box.

vroid_export_vrm(path, avatar_name, creator, version='1.0')

The whole Export-as-VRM walk, including the VRM Settings metadata modal and Wine's save dialog. version picks VRM1.0 or VRM0.0.

vroid_save_project(name?)

Ctrl+Shift+S to an explicit .vroid path, or a plain Save with no argument.

Every acting tool focuses VRoid first and refuses to act if the focused window is not VRoid Studio.

How it works

The loop is see → locate → act → see again:

  1. vroid_launch()

  2. vroid_screenshot() — the image goes to the client's model, which looks at it

  3. vroid_find_text("Export") or vroid_find_button() for coordinates

  4. vroid_click(x, y) — always with coordinates from a fresh capture

  5. vroid_screenshot() to confirm what actually happened

Seeing is grim on the Hyprland window geometry, then tesseract for word boxes and OpenCV for solid-colour buttons (VRoid's primary pills are #0096FA, and OCR reliably loses white-on-blue labels).

Acting goes down two different paths, for annoying reasons:

  • Pointer — a small C client (native/vpointer.c) speaking zwlr_virtual_pointer_unstable_v1. It moves the real compositor cursor, so hover states and drags behave exactly as they do for a human, and it needs no permissions: ydotool's /dev/uinput route is 0600 root:root and would need sudo or a udev rule.

  • Keyboard and wheel — X11 XTEST through Xwayland, because the virtual-pointer protocol has no keyboard counterpart and VRoid is an Xwayland client anyway.

Coordinate spaces. Three are in play and they are all different:

space

size on the reference machine

who uses it

Hyprland layout (logical)

2048 × 1152

hyprctl, the virtual pointer

image pixels of a capture

2560 × 1440

tesseract, cv2, everything you see

X11 pixels (Xwayland)

2560 × 1440

XTEST

Tools take and return image px (space="image") by default and convert internally, so vroid_find_text output can be handed straight to vroid_click. If MCP_VROID_MAX_IMAGE_PX downscaled the picture you were shown, multiply coordinates read off it by the inverse of the reported downscale — or just ask vroid_find_text, which always reports native px.

Rules of thumb, learned the hard way:

  • Read the whole frame, not a crop. A "Close Hairstyle Editor" confirm modal sat in the middle of the screen through six failed clicks because the check only OCR'd the top 60 px.

  • Don't judge change by the 3D viewport. VRoid dithers every frame, so a full-window diff reads ~0.98 even when nothing happened. Watch a UI strip.

  • Prefer numeric boxes to slider drags. vroid_set_slider types an exact value; dragging is for controls that have no box.

  • Primary buttons are found by colour, not text. A grey pill where you expect blue is the app telling you a required field is empty.

A detailed map of VRoid's UI — tab strip, rails, panels, the export flow, the hair editor, with measured coordinates — is in docs/ui-map.md.

Limitations and brittleness

This is GUI automation with no API underneath. Be realistic about it:

  • OCR is the whole locating story, and it is imperfect. Small, letter-spaced or light-on-dark labels get split or dropped (Export → E + xport). White-on-blue is lost entirely, which is why vroid_find_button exists. Icons have no text at all — those anchors are hard-coded fractions of the window.

  • Coupled to the UI version. Needles and fractional anchors were calibrated on VRoid Studio 2.14.0, English, at 2560×1440 / scale 1.25. A pixiv UI reflow, another language, or a different monitor can require re-measuring. (Japanese UI → kebab ⋮ → Settings → Language.)

  • Modals appear outside your search region and swallow clicks silently.

  • Timing is guessed. The 3D viewport takes ~5 s after a base is chosen; export takes 5–30 s, longer for heavy models.

  • The Wine save dialog is a separate window with its own class and geometry — use vroid_screenshot(whole_screen=true) there.

  • Single instance, single session. One VRoid window, one desktop, no headless mode, no parallelism. It drives your screen.

  • The idle screensaver can grab the session mid-run. The guard refuses to type into it and closes that one window (and only that one) before acting.

  • Attended use is recommended. See below.

Security

This server injects real mouse and keyboard events into your live desktop session and takes screenshots of it. That is the entire point, and it is also the risk:

  • Screenshots may capture anything on the output — whole_screen=true captures everything, and captures are written to disk unencrypted.

  • Keystrokes go to whatever holds keyboard focus. The driver refuses to act unless VRoid Studio is focused, but a careless or hostile prompt can still click anywhere inside VRoid.

  • vroid_launch(restart=true) kills VRoid Studio and loses unsaved work.

  • Nothing here is sandboxed and there is no confirmation step.

Run it attended, on a session you are watching. Don't run it on a shared or multi-user machine, don't leave an agent driving it unsupervised, and treat the captures directory as sensitive. vroid_release() gives the desktop back when you're done.

Development

uv run python scripts/smoke_test.py              # start the server, list tools, call vroid_status
uv run python scripts/smoke_test.py --screenshot # + one passive capture if VRoid is open
uv run vroid-driver shot                         # the original driver CLI, still here

vroid-driver (mcp_vroid.driver.cli) is a shell interface to the same engine — launch, shot, find, click, tab, slider, export, cam, apply-params, … — handy for debugging without an MCP client in the loop.

Layout:

src/mcp_vroid/server.py       MCP tool definitions (stdio)
src/mcp_vroid/session_env.py  recovers the Wayland/X session env
src/mcp_vroid/driver/         the engine
  window.py                   hyprctl: find / launch / focus / workspaces   <- Hyprland-specific
  capture.py                  grim + coordinate spaces
  locate.py                   tesseract OCR + colour button matching
  input.py                    vpointer (Wayland) + XTEST (X11)
  actions.py                  the VRoid-specific flows
native/vpointer.c             zwlr_virtual_pointer client

Contributing

Issues and PRs welcome. Useful things to bring:

  • A port to another wlroots compositor. Only driver/window.py is Hyprland-specific; the rest already works anywhere grim and zwlr_virtual_pointer do.

  • Anchors for other resolutions or DPI scales, or for the Japanese UI.

  • Bug reports — include your compositor, VRoid Studio version, monitor resolution and scale, and the output of vroid_status. A capture from the failing step helps enormously.

Nothing here is auto-formatted or linted by CI; match the surrounding style.

Licence

MIT — see LICENSE. VRoid Studio is a product of pixiv Inc.; this project is unaffiliated with pixiv and simply drives the app's UI.

Available Tools

18 tools
vroid_clickA

Click a point in the VRoid window with the real compositor cursor.

Refuses unless VRoid Studio is the focused window; it focuses the window itself first (workspace 9, fullscreen) and raises rather than clicking into somebody else's app.

The pointer glides to the target in a few steps so hover states fire, then clicks and settles ~0.35 s. Coordinates must come from a CURRENT capture - take a fresh vroid_screenshot or vroid_find_text right before clicking, because panels reflow and modals move.

Inputs: x (required), y (required), space, button, +1 more. Returns: object

vroid_current_screenA

Identify which VRoid screen is on top: start, editor, export_vrm, hair_editor, or unknown.

Cheap-ish (OCRs only the top strip, ~2 s) and worth calling whenever a flow tool fails - most failures are "you are not on the screen this tool expects". 'unknown' usually means a modal is up; screenshot the whole window and look.

Inputs: no named parameters. Returns: object

vroid_dragA

Press, glide and release - slider handles, the 3D camera, hair guides.

For Parameters sliders prefer vroid_set_slider, which types an exact value into the row's numeric box; dragging is only for controls that have no numeric box. The glide is 24 interpolated steps, which is what the app needs to register a drag rather than a click.

Inputs: x1 (required), x2 (required), y1 (required), y2 (required), +2 more. Returns: object

vroid_export_vrmA

Walk the entire Export-as-VRM flow and write a .vrm file.

Editor toolbar share icon -> 'Export as VRM' -> the blue Export pill -> the VRM Settings modal (fills Avatar Name and Creators, picks the export format, scrolls to the bottom and clicks Export) -> Wine's save dialog (types a Z:\ path and presses Return) -> waits for the file size to stop growing.

Must be started from the EDITOR screen with a model loaded. Takes 30 s to a few minutes depending on the model. Returns the written path and its size; raises with the path of a diagnostic screenshot if any step fails - read that screenshot before retrying, since a half-finished flow usually leaves a modal open that the next attempt will trip over.

Inputs: path (required), creator (required), avatar_name (required), timeout, +1 more. Returns: object

vroid_find_buttonA

Find VRoid's primary action buttons by their colour, not their text.

VRoid's confirm buttons ('Export', 'OK', 'Create') are solid #0096FA pills whose white labels tesseract regularly loses, so this matches the button chrome. Blobs come back biggest-first, in image px, ready for vroid_click.

A grey pill (color='disabled') where you expected a blue one means the action is disabled - on the VRM Settings modal that means Avatar Name or Creators is still empty.

Inputs: color, label, limit, region. Returns: object

vroid_find_textA

OCR the current VRoid window and return where query appears.

Takes its own fresh screenshot, so coordinates are current. Results are image px, ordered by OCR confidence, ready to pass straight to vroid_click (which defaults to space='image').

Known OCR weaknesses in this UI: small, letter-spaced or light-on-dark labels get split or dropped ('Export' -> 'E' + 'xport'), and white text on VRoid's blue primary buttons often disappears entirely - use vroid_find_button for those. Icons (toolbar, left rail) have no text at all; the README's UI map has their fractional positions.

If nothing is found, that is information: the screen may not be the one you think it is, or a modal is covering it. Take a screenshot and look.

Inputs: query (required), exact, limit, region. Returns: object

vroid_keyA

Press a key combination in the focused VRoid widget.

Useful ones: Return confirms a value box or a Wine dialog's default button; ctrl+s saves the project; ctrl+shift+s is Save As; ctrl+z/ctrl+y undo/redo in the editor.

Note Escape does NOT close VRoid's hamburger menu - click elsewhere to dismiss it.

Inputs: combo (required), times. Returns: object

vroid_launchA

Start VRoid Studio (Steam appid 1486350, Proton) and take control of it.

Idempotent: if the window already exists it is reused, not relaunched. Then the window is parked on Hyprland workspace 9, the workspace the user was on is remembered (vroid_release puts them back), and the window is focused and fullscreened so its geometry - and therefore every coordinate you will read off a screenshot - is stable.

Cold start over Proton takes 30-90 s; the call blocks until the window is up. It does NOT wait for the start screen to finish drawing, so take a vroid_screenshot and look before clicking anything.

Inputs: restart, timeout. Returns: object

vroid_new_characterA

From the start screen: Create New -> pick a base -> land in the editor.

Locates the 'Create New' tile by text (so it survives the Recently Edited grid growing), clicks the card above its caption, picks the base thumbnail, then waits up to a minute for the editor - the 3D viewport takes several seconds to appear after a base is chosen.

Inputs: base. Returns: object

vroid_open_tabA

Switch the editor to a top-level tab, located by OCR of the tab strip.

Only works on the editor screen (not the start screen, the export screen or the hair editor). Waits ~2 s for the panels to redraw and returns the path of a verification screenshot - take a vroid_screenshot if you want to see the result.

Inputs: name (required). Returns: object

vroid_releaseA

Hand the desktop back: switch to the workspace the user was on before.

Leaves VRoid running on workspace 9. Call this when you are done with a session, or before handing control back to the human.

Inputs: no named parameters. Returns: object

vroid_save_projectA

Save the .vroid project - plain Save, or Save As to an explicit path.

With name: presses Ctrl+Shift+S and drives Wine's save dialog the same way the VRM export does, then waits for the file. Without name: opens the hamburger menu and clicks Save, which overwrites the project's existing file and opens the Wine dialog only if the project has never been saved (in that case call this again WITH a name).

Worth doing before any risky experiment: nothing else in this server persists your work, and vroid_launch(restart=true) discards it.

Inputs: name, timeout. Returns: object

vroid_screenshotA

Screenshot VRoid and return the image, plus the path it was saved to.

LOOK at the returned image before you decide anything - this is the only way to see the app. Every capture is also written to the captures dir so it can be re-read later.

Coordinate space: the reported image_size is the native capture size (2560x1440 on the reference machine) and that is the space every other tool means by space="image". The transported image may be downscaled (downscale in the text block says by how much); if you read a coordinate off the picture by eye, divide it by that factor before clicking. Better: get coordinates from vroid_find_text / vroid_find_button, which always report native image px.

Gotchas carried over from the driver: capture the WHOLE window, not a crop, when checking "did that work" - modals appear in the middle of the screen and a top-strip-only check will miss them. And do not judge change by the 3D viewport, which dithers every frame; watch a UI strip instead.

Inputs: tag, region, whole_screen, full_resolution.

vroid_scrollA

Wheel-scroll a panel or zoom the 3D viewport.

Sent as X11 button 4/5 (6/7 horizontally): VRoid is an XWayland client and ignores Wayland virtual-pointer axis events, so this is the only wheel that works on it.

Long Parameters lists need this - a label that vroid_find_text cannot see is usually just below the fold. vroid_set_slider scrolls to its own row automatically, so you rarely need to do it by hand.

Inputs: x, y, dx, dy, +1 more. Returns: object

vroid_set_colorA

Set a colour swatch by typing a hex code into its #RRGGBB box.

Scrolls the right-hand panel to the labelled row, clicks the hex field just below the label, replaces its contents and presses Return. Same caveat as sliders: the row must belong to the currently open tab and sub-category (left icon rail).

Inputs: hex (required), label (required). Returns: object

vroid_set_sliderA

Set a Parameters slider exactly, by typing into its numeric box.

Scrolls the right-hand panel from the top until the labelled row is visible (so it works for parameters below the fold), clicks the numeric box at the end of that row, clears it, types the value and presses Return.

This is far more reliable than dragging the handle - use vroid_drag only for controls with no numeric box. Open the right tab first (vroid_open_tab): each tab has its own parameter list.

Inputs: label (required), value (required). Returns: object

vroid_statusA

Report whether VRoid Studio is running, focused, and where it sits.

Cheap and side-effect free (no OCR, no input) - use it before anything else, and after anything that might have lost focus. focused false means every acting tool will refuse until vroid_launch (or any acting tool's own focus step) takes the window back.

Also reports whether the external helpers this server needs are present: the vpointer binary (built by native/build.sh), grim, tesseract, hyprctl.

Inputs: no named parameters. Returns: object

vroid_typeA

Type into whatever widget currently has keyboard focus.

Click the field first (vroid_click) - this tool has no idea where the caret is. Keystrokes go through X11 XTEST because the Wayland virtual keyboard is mis-read by this Proton client (a whole string arrives as a single character).

Always screenshot afterwards to confirm the text landed in the field you meant: VRoid's forms have several boxes with near-identical captions, and typing into the wrong one leaves the primary button greyed out with no other symptom.

Inputs: text (required), clear_first. Returns: object

Tool Schema Changelog

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

Loading tool schema changelog…

    TDQS

    A4.4/5.0

    Scored across 18 tools

    Disambiguation5/5

    Each tool addresses a distinct capability: launching, screen identification, OCR, clicking, typing, saving, exporting, etc. Even the 'seeing' tools (screenshot, find_text, find_button) have clearly separated purposes: one captures, one finds text, one finds buttons. The interaction primitives (click, drag, scroll, type, key) are mutually exclusive and well-defined. No two tools could be confused for the same action.

    Naming Consistency4/5

    All tools share the 'vroid_' prefix and mostly follow a verb_noun pattern (open_tab, export_vrm, set_slider), but a few are single verbs or nouns (status, release, screenshot, click, type). The naming is predictable and readable, with only minor deviations like 'status' and 'release' being not strictly verb_noun. Overall consistent enough for an agent to infer actions.

    Tool Count4/5

    18 tools is on the higher side but justified for a GUI automation server that needs primitives for every interaction type plus higher-level workflows like export and save. The count feels well-scoped for the domain—each tool earns its place since there's no redundant functionality. Slightly heavy but still reasonable.

    Completeness5/5

    The surface covers the full lifecycle: launch, status, screen detection, navigation (open_tab, scroll), interaction (click, drag, type, key, sliders, colors), inspection (screenshot, OCR), persistence (save_project), export (export_vrm), and creation (new_character). No obvious gaps—even edge cases like disabled buttons and modal detection are addressed. The server appears fully equipped for its stated purpose of automating VRoid Studio.

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      D
      maintenance
      Enables AI agents to control the Blockout previs desktop app for AI filmmaking, allowing staging of 3D worlds, character animation, camera framing, timeline control, and viewport screenshotting through MCP tools.
      7
      Apache 2.0
    • A
      license
      Not graded
      quality
      D
      maintenance
      Enables AI assistants to control a desktop virtual character (VRM) by playing animations, showing/hiding the character, and checking runtime status through the MCP protocol.
      768,733 npm
      2
      MIT
    • A
      license
      Not graded
      quality
      C
      maintenance
      Enables Windows MCP clients to author MikuMikuDance scenes by controlling characters, cameras, lighting, physics, timeline editing, MME effect assignments, and AVI video output through natural language or scripted tool calls.
      45
      MIT No Attribution