Skip to content
karagöz docs

MCP server

How to register the Karagöz MCP server with each client, and what its tools take, return and do.

~/app
karagoz mcp

Starts an MCP server for an AI agent on stdin and stdout. It opens no port: the client starts the process and talks to it over stdio. The 15 commands are its tools, and each tool returns the JSON line the CLI prints.

mcp takes no arguments or options. It exits with 0 when stdin closes, 130 on SIGINT and 143 on SIGTERM.

Karagöz is not on npm yet, so every example runs the built file by its absolute path. Replace /abs/path/karagoz-mcp with the folder you cloned it into.

Claude Code and Codex were run against this server. The Claude Desktop, Cursor and VS Code entries follow their documentation and were not run.

~/app
claude mcp add karagoz -- node /abs/path/karagoz-mcp/packages/karagoz/dist/cli.js mcp

The server starts in the directory claude started in, with the shell’s environment. A call still running after 2 minutes becomes a background task.

On macOS in ~/Library/Application Support/Claude/claude_desktop_config.json, on Windows in %APPDATA%\Claude\claude_desktop_config.json:

~/Library/Application Support/Claude/claude_desktop_config.json
{
"mcpServers": {
"karagoz": {
"command": "node",
"args": ["/abs/path/karagoz-mcp/packages/karagoz/dist/cli.js", "mcp"],
"env": { "ANDROID_HOME": "/Users/me/Library/Android/sdk" }
}
}
}

Quit and restart Desktop after editing.

Desktop starts servers with part of your environment and possibly / as the working directory. Set ANDROID_HOME in env when adb is not on the PATH it passes, and use absolute paths.

In .cursor/mcp.json in the project or ~/.cursor/mcp.json:

.cursor/mcp.json
{
"mcpServers": {
"karagoz": {
"type": "stdio",
"command": "node",
"args": ["/abs/path/karagoz-mcp/packages/karagoz/dist/cli.js", "mcp"]
}
}
}

In .vscode/mcp.json in the workspace:

.vscode/mcp.json
{
"servers": {
"karagoz": {
"type": "stdio",
"command": "node",
"args": ["/abs/path/karagoz-mcp/packages/karagoz/dist/cli.js", "mcp"]
}
}
}

Or from the command line: code --add-mcp '{"name":"karagoz","command":"node","args":["/abs/path/karagoz-mcp/packages/karagoz/dist/cli.js","mcp"]}'.

In ~/.codex/config.toml:

~/.codex/config.toml
[mcp_servers.karagoz]
command = "node"
args = ["/abs/path/karagoz-mcp/packages/karagoz/dist/cli.js", "mcp"]
env_vars = ["ANDROID_HOME", "ANDROID_SDK_ROOT", "KARAGOZ_DEVICE", "ANDROID_SERIAL", "DEVELOPER_DIR"]
tool_timeout_sec = 600
  • Codex passes a server only a short list of environment variables. env_vars forwards the ones Karagöz reads.
  • tool_timeout_sec covers long tap waits and big installs.
  • install and uninstall ask for approval. codex exec never asks, so it refuses them with MCP tool call requires approval, but approval policy is never.
ToolCommandArgumentsMarked
devicesdevicesnoneread-only
screenshotscreenshotout, inline, device
ui_treeui-treedeviceread-only
taptapx, y, text, id, duration, timeout, device
swipeswipex1, y1, x2, y2 (required), duration, device
texttexttext (required), device
keykeykey (required), device
installinstallapk (required), devicedestructive
launchlaunchpackage (required), device
terminateterminatepackage (required), device
uninstalluninstallpackage (required), devicedestructive
logslogspackage, since, lines, deviceread-only
doctordoctornoneread-only
install_runnerinstall-runnerteam, deviceopen world
uninstall_runneruninstall-runnerdevicedestructive

ui_tree is the one renamed tool, because Codex turns - into _.

“Marked” is the tool’s annotation: readOnlyHint, destructiveHint, or openWorldHint for install_runner, whose build talks to Apple’s developer service. The other 8 carry destructiveHint: false, the other 14 openWorldHint: false, and all 15 a title. Clients use these to decide what needs approval and what may run in parallel.

  • The names are the CLI’s option and positional names: tap {"x": 540, "y": 1200} is karagoz tap 540 1200, and logs {"lines": 5} is karagoz logs --lines 5.
  • Values go through the CLI’s own checks, as the text of the value: 12.5 is 12.5. The messages keep CLI syntax: tap {"x": 1, "y": 2, "duration": 1.5} fails with --duration must be a whole number of milliseconds (got '1.5').
  • null counts as not given.
  • An argument the tool does not take is refused: 'devices' does not take the option '--foo'.
  • inline exists only here: true or false, else INVALID_ARGS inline must be true or false (got 'yes').
  • out and apk should be absolute. A relative path resolves against the server’s working directory, which the client picks.
  • device picks one of several devices. Without it the server uses KARAGOZ_DEVICE, then ANDROID_SERIAL from its own environment, then the only device (Device selection). Codex passes these variables only through env_vars.
  • Success: one text block with the command’s JSON line.
  • Failure: isError: true and one text block with the CLI’s error envelope. karagoz: <message> goes to stderr, as in the CLI.
  • An unknown tool name is a JSON-RPC error, -32602 Unknown tool: <name>.
  • screenshot with inline: true adds the full-resolution PNG as an image block. Clients scale it before the model sees it, so take coordinates from ui_tree bounds, or from pixels and scale, never from the image.

A real exchange, one line per message, --> sent and <-- received. The data string is cut.

--> {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"devices","arguments":{}}}
<-- {"result":{"content":[{"type":"text","text":"{\"devices\":[{\"id\":\"emulator-5554\",\"platform\":\"android\",\"kind\":\"emulator\",\"state\":\"device\",\"name\":\"Medium_Phone_API_36.1\"}]}"}]},"jsonrpc":"2.0","id":2}
--> {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"tap","arguments":{"x":1}}}
<-- {"result":{"content":[{"type":"text","text":"{\"error\":{\"code\":\"INVALID_ARGS\",\"message\":\"'tap' needs <x> <y>\"}}"}],"isError":true},"jsonrpc":"2.0","id":3}
--> {"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"screenshot","arguments":{"out":"/Users/me/home.png","inline":true}}}
<-- {"result":{"content":[{"type":"text","text":"{\"path\":\"/Users/me/home.png\",\"device\":\"emulator-5554\",\"pixels\":{\"width\":1080,\"height\":2400},\"logical\":{\"width\":411.42857142857144,\"height\":914.2857142857143},\"scale\":2.625,\"safeArea\":{\"top\":63,\"right\":0,\"bottom\":63,\"left\":0},\"rotation\":0}"},{"type":"image","data":"iVBORw0KGgoAAAANSUhEUgAABDgAAAlgCAYAAABt...","mimeType":"image/png"}]},"jsonrpc":"2.0","id":4}
--> {"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"ui-tree","arguments":{}}}
<-- {"jsonrpc":"2.0","id":5,"error":{"code":-32602,"message":"Unknown tool: ui-tree"}}
  • Calls run in parallel, except UiAutomation reads (ui_tree, and tap with text or id), which take turns per device inside one server. Another process that holds UiAutomation still causes AUTOMATION_BUSY.
  • A cancelled call kills its adb child at once and gets no response. The device does not stop with it:
    • It keeps its UiAutomation slot for about 1-2 s, so a ui_tree right after can get AUTOMATION_BUSY.
    • A gesture already sent finishes on the device.
    • A cancelled install may leave the app installed.
  • In Claude Code, Esc cancels the running call.
  • stdin closing, SIGINT and SIGTERM cancel every call the same way.
  • The server keeps the adb it found for the whole session. If that file disappears (ENOENT), the next call looks it up again. An adb that appears mid-session higher in the lookup order is not picked up until the server restarts.

What the server adds to a Claude Code session: input tokens of the first API call with Karagöz registered, minus the same call with no MCP server. The numbers come from Claude Code 2.1.306 and claude-opus-5-5, on 2026-10-21.

Case Tokens
Every session, tool search on (the default): the 15 tool names and instructions 370
All 15 definitions, loaded by one ToolSearch call 2,750
Every session, tool search off (ENABLE_TOOL_SEARCH=false): every definition and instructions 2,632

The tools/list result is 7,152 characters, instructions 398. One ui_tree of the launcher home screen is 5,383 characters.