okinawaengine GitHub

MCP server

Okinawa ships an optional in-engine MCP (Model Context Protocol) server. It lets an external agent connect to a running app over local HTTP and observe or drive it through the tools listed below. The public surface (OkMcpServer) deliberately exposes no HTTP or JSON types: all of that lives behind a pimpl, so consumers do not inherit those dependencies.

Tools

The server exposes these tools to a connected agent:

Tool What it does
view_frame Returns the current rendered frame as a PNG image, so the agent can see what is on screen.
screenshot Writes the current frame to a PNG file on disk (for a human) and returns the path. Optional path (default okinawa-screenshot.png).
press_key Holds a key for a duration to drive the app for gameplay: W/A/S/D move, SPACE/T/R/F are actions, 1-9 switch camera, arrows turn. Args: key, duration_ms (default 120). (For positioning the view, use view instead.)
press_keys Holds several keys at once (e.g. W and D for diagonal movement). Args: keys, duration_ms.
mouse The pointer tool. Moves the pointer, presses its buttons, drags with one held, or turns the wheel. action is move, down, up, click, drag or wheel. Everything is in window pixels, origin at the top left -- the same frame a screenshot is in, so a place seen in an image can be named here without converting anything. move takes an absolute x,y or a relative dx,dy; drag goes from x,y to to_x,to_y with the button held, delivered in steps moves (default 12); wheel turns by notches, positive away from the hand. button is left, right or middle (default left). Returns where the pointer ended up. See below on what an application has to do to receive it.
view The camera tool. Sets the whole viewpoint in one call. camera (optional) activates a camera by its registered name (get_state lists them under cameras); the tool then drives the active camera and never force-switches on its own. x, y, z place the avatar. Orbit cameras take yaw_deg (compass facing), pitch_deg (tilt; negative looks down, ~-89 = top-down) and distance (metres back); overhead/fixed cameras take just distance (their height). All fields optional; an omitted field keeps its current value. Persistent (survives input). get_state returns the same values, with the active camera's name, under view — reproduce any viewpoint by passing them straight back. Returns the resulting view.
set_item_visible Show/hide scene items by name to isolate geometry. With prefix: true it applies to every item whose name starts with name (e.g. tree_ to hide every tree at once, or tree_oak_ to narrow it further); otherwise it toggles the single item with that exact name. Returns how many items changed.
console Runs one console command line, exactly as if it had been typed into the drop-down console and submitted, and returns what it printed. Arg: line (e.g. time 22, set shadows.cascades 3); with no line it returns the registered command names instead. The console does not need to be open, and its open state is left as it was found. This is how an agent drives the console — see the note below on why typing it key by key is not a real option.
config Reads or writes an engine config key at runtime — the same keys the console's set/get reach (shadows.*, render.*, graphics.*, plus any the application registers). With no value it reads; with one it writes, converting to the key's existing type. Pass prefix and no key to list every matching key with its current value.
get_performance Returns the frame-time series, not a single reading: count, frame_ms and fps each with min/max/mean/median, and a hitching flag (true when the mean sits well above the median, which is what occasional long frames look like in a summary). Also returns draw_ms (min/max/mean/median), the CPU time spent issuing the frame's draws, measured before the swap: where vsync is enforced by the platform, frame_ms is pinned to the refresh interval and cannot tell whether a change cost anything, and draw_ms is the number to compare. Pass samples: true for the raw per-frame milliseconds, oldest first. Samples are recorded whether or not the stats panel is visible.
quit Closes the application, the same way its window's close button does — including that an application which has registered a close callback (see OkCore::setCloseCallback) may take it back and ask a question instead, in which case the process stays up. Otherwise the loop stops, the application's exit callback runs (see OkCore::setExitCallback) so it can save whatever it keeps between sessions, and the process ends. The reply is sent before the shutdown starts, and it is the last thing this server answers — a client that keeps the connection will see it drop. Reaches the same askForExit() as the console's own quit command, and is declared as a tool for the same reason config is: a tool appears in tools/list, so an agent finds it without having to know there is a console behind it.
input Reads or sets whether the person at the window can drive with their own keyboard and mouse. With no arguments it reports. enabled: false takes the keyboard, so a stray key cannot move the view under a measurement; enabled: true gives it straight back. A block expires on its own after seconds (default 300, clamped to 1..3600) and can always be lifted at the window with ctrl+shift+k; while it holds, the app says so on screen. Injected input (press_key, view) is unaffected either way. See below.
get_state Returns numeric runtime state, including view (the active camera's name and values, ready to pass back to view), cameras (the registered camera names), the raw camera pose, fps, scene object count, window size, resident memory, and input (whether the person at the window can drive, for how much longer they cannot, and what the keyboard has been doing).

Key names accepted by press_key/press_keys: single letters and digits, space, up/down/left/right, escape, enter, tab, backspace, grave (or backtick), period, minus. grave toggles the engine console; while the console is open, injected printable keys feed its input line, so a command can be typed a key at a time (grave, then the letters with space/period, then enter).

Do not drive the console that way. Each key is one MCP call with its own round trip, so a fifteen-character line costs sixteen of them and takes about as many seconds — long enough that an agent stops reaching for the commands it most needs while debugging. Use console instead: one call, one line, and the command's own output comes back as the result.

Driving the pointer

press_key reaches anything bound to the keyboard, which in a game is most of it. An application with an interface of its own -- panels, menus, things dragged into place -- is a different matter: none of that is reachable from a keyboard, so without a pointer an agent working on one has to ask a person to make the gesture and send a picture back.

Say how far, and in what. "Move up" is not an instruction: up by how much, measured in what? Every number this tool takes is in the pixels get_state reports under window, origin at the top left -- the frame a screenshot is made of, so a position read off an image can be handed straight back.

That frame is the framebuffer, and on a screen with more pixels than points an interface is laid out in half as many: the tool converts, so a caller never has to know what display it is drawing on. What a caller does have to allow for is a picture that arrived scaled down on the way -- multiply by window.width over the image's own width first.

{"action": "move",  "x": 700, "y": 400}
{"action": "move",  "dx": 0, "dy": -120}
{"action": "click", "x": 30, "y": 9, "button": "left"}
{"action": "drag",  "x": 1100, "y": 500, "to_x": 1250, "to_y": 420, "steps": 10}
{"action": "wheel", "notches": 3}

Two things are deliberate and neither is free to change:

  • A click holds its button for about four frames. An interface that never sees the button held does not see a click at all.
  • A drag is delivered in steps, a frame apart. Half of what an interface does with a drag it does while the drag is happening; one jump from start to end is a gesture whose middle nothing sees.

What the application has to do

The engine keeps the injected pointer and says it has been used; applying it is the application's business, exactly like the injected keys. An application that draws its own interface with a UI library has to hand the pointer to that library itself -- the library's backend reads the window system directly, and an injected pointer is not the window system. With Dear ImGui, between the backend's new frame and your own:

OkInput *input = OkCore::getInput();
if (input != nullptr && input->injectedPointerUsed()) {
  ImGuiIO &io = ImGui::GetIO();
  double px = 0.0;
  double py = 0.0;
  input->injectedPointer(&px, &py);
  io.AddMousePosEvent(static_cast<float>(px), static_cast<float>(py));
  for (int b = 0; b < 3; b++) {
    io.AddMouseButtonEvent(b, input->injectedButton(b));
  }
  double wheel = input->takeInjectedWheel();   // read once: clears it
  if (wheel != 0.0) {
    io.AddMouseWheelEvent(0.0f, static_cast<float>(wheel));
  }
}

takeInjectedWheel clears what it returns, so read it once a frame: a notch delivered twice is one turn of the wheel doing two steps.

And ask injectedPointerUsed() every frame rather than remembering the answer. The injected pointer goes quiet two seconds after the last call, and the instant the physical mouse moves: a hand on the desk beats anything driving from outside. Fed unconditionally once it had been used, it pushes a stale position every frame and the person at the window finds their own mouse does nothing.

Taking the keyboard, and giving it back

An agent measuring something needs the view to hold still, and a person at the same window will move it without meaning to. The launch flag --no-input-style gate (OkCore::setIgnoreUserInput) has always been able to stop that, but only at start-up: changing your mind meant relaunching, which throws away the viewpoint that was being measured.

input is the same gate at runtime. {"enabled": false} takes the keyboard for the agent, {"enabled": true} hands it back, and both report the resulting state.

The part worth understanding is what happens when the agent does not hand it back — because that is the failure this is built around, not an edge case. A block is invisible by nature: the keyboard simply stops answering, which from a chair looks exactly like a hang. So:

  • it expires. seconds (default 300) is a deadline, not a hint. An agent that crashes, loses its connection or simply forgets costs the person a wait, never a lockout;
  • the window always wins. ctrl + shift + k lifts any block, matched on the device itself rather than on what the application is allowed to see. Nothing running in the background can override that, and the key of the combo is then taken out of the frame until it is let go, so the application never sees it -- a release that hands control back by quitting the app, or by walking the avatar off, is not a release. (Not escape: macOS does not deliver Escape to an application while Control is held, measured on the device with the modifiers arriving and the Escape never doing so.);
  • it is on screen. A line in the corner says the input is held, how many seconds are left, and which keys end it.

Both also report device: keys_down, what the keyboard is holding right now, and recent_presses, the last few keys it reported going down with the modifiers held at the time and how long ago. An agent cannot press a key, so when a gesture does not work this is the only way to tell a key that never arrived from a match that is wrong. It earned its place: three rounds of guessing at a combo that would not fire, then one reading -- macOS does not deliver Escape while Control is held.

{"seconds": 0} blocks with no deadline. It exists because the launch flag needs it; an agent asking for it is asking to be the thing the three rules above were written about.

Measuring performance

get_state reports an instantaneous fps. That number is close to useless on its own: it describes one frame, from one viewpoint, and two such readings cannot tell a real change from a lucky moment. Use get_performance, which returns the whole recorded window.

Two things worth knowing when reading the result:

  • Compare like with like. The same build reads completely differently close to the ground and from above. State the viewpoint alongside the number, and when comparing two builds, measure both from the same place — ideally in the same session.

  • Watch for quantisation. With vsync on, frame times land on multiples of the refresh interval: on a 60 Hz display a median of 16.7, 33.3 or 50 ms means one, two or three refreshes per frame. A median pinned to exactly 33.3 ms says the frame costs somewhere between 16.7 and 33.3 ms, not that it costs 33.3, and a small extra cost will drop it a whole step to 50 ms. Read min alongside the median to see where the real work sits.

    This also explains a frame rate that seems to halve for no reason: near the boundary, a couple of milliseconds either way moves the whole frame between one refresh and two. render.vsync 0 asks for vsync off so the times show the real work instead — but a compositor can enforce it anyway (macOS does), in which case the readings stay quantised and only samples_ms tells the full story.

When a call fails

A tool that cannot do what was asked answers with an ordinary result carrying isError: true and a line of text saying why — an unknown camera name, a key that does not exist, a request that arrived while nothing was rendering. The HTTP status stays 200: the call was delivered and answered, and what failed is the tool, not the transport.

The same applies to a malformed argument. Sending a string where a number belongs is reported as bad arguments: ... rather than being allowed to reach the JSON library and abort the request, so a client that gets a field wrong is told which one instead of receiving an empty response.

An argument the tool does not have is refused outright, naming it and listing the ones the tool does take:

console: no such argument: command. It takes: line

This matters more than it looks. Every schema says additionalProperties: false, and an unknown argument used to be dropped, leaving the tool to run on whatever was left — which for a tool whose arguments are all optional is a call with no arguments at all. That is a different call, it succeeds, and it answers tidily, so a caller with one field misspelled can drive an application for a long time believing it is being obeyed.

Enabling it

From application code, enable the server after OkCore::initialize():

OkCore::enableMcpServer();        // binds 127.0.0.1:8765
OkCore::setIgnoreUserInput(true); // optional: MCP-only control

enableMcpServer(int port = 8765) always exists. If the engine was built without MCP support it logs a warning and does nothing, so apps compile identically with or without the server. The server binds 127.0.0.1 on the given port and logs a line such as MCP server listening on http://127.0.0.1:8765/mcp.

Connecting a client

Once a build with the server enabled is running (it logs MCP server listening on http://127.0.0.1:8765/mcp), point your MCP client at that URL. The transport is streamable HTTP.

With the Claude Code CLI, add it on the command line:

claude mcp add --transport http okinawa http://127.0.0.1:8765/mcp

This registers the server (named okinawa here) at local scope. Reload or reconnect the client so it picks up the tools, then the tools listed above are available. Remove it later with claude mcp remove okinawa. The server only exists while the app runs, so start the app and reconnect each session.

Compile-time toggle

The server is compiled in by default and guarded by OKINAWA_WITH_MCP (xmake option mcp). Exclude it from lean builds with xmake f --mcp=n, or be explicit with --mcp=y. When excluded, no MCP/HTTP code or its header-only dependencies land in the binary.

OkMcpServer methods

OkMcpServer is normally managed by OkCore; you rarely construct it directly. Its public surface:

Method Purpose
OkMcpServer(int port) Construct bound to a port.
void start() Start the HTTP server thread.
void stop() Stop the server and join its thread.
void drainCommands() Run queued tool commands on the engine loop thread.

drainCommands() must be called once per frame from the engine loop thread (the one holding the OpenGL context), after the scene is rendered and before the buffers are swapped. OkCore::loop does this for you when the server is enabled.