AI-native CLI bridge that lets a terminal-bound process — Claude Code, a shell user, anything else — drive the live RStudio session running on the same machine: open files, run R code, list and read terminals, inspect the live R environment, surface lint-style markers, manage background jobs, install a Claude Code skill, and more.
The binary is named rstudio. It auto-detects which RStudio it's
talking to:
- RStudio Server (Linux): the rsession Unix socket. Works from inside the embedded terminal or any other shell on the same host (env vars are auto-discovered via the rsession socket directory).
- RStudio Desktop (macOS): the rsession TCP loopback with the shared secret, auto-discovered from the running rsession process.
Either way the CLI reuses the active browser/IDE client, so visible actions land in the user's open tab/window without disrupting it.
Claude Code's /ide slash command connects the agent to VS Code or
JetBrains (as of this writing — the list may evolve). The agent and
the IDE share state: open files, diagnostics, selection. /ide does
not cover RStudio.
Installing the skill (rstudio skill install) closes that gap by
adding /rstudio as a slash command in Claude Code. Typing
/rstudio invokes the skill, which tells the agent how to drive
your live RStudio session — open files, run R, surface markers,
inspect the live environment, manage Jobs — through the CLI, without
disrupting your browser tab.
v0.21.4 — covers ~50 of the 117 functions exported by rstudioapi,
across 16 categories and 106 actions. First-class support for R's
debugger (browser(), debug(), recover()): r send / r exec
auto-target the active browser frame, every response carries an
eval_env field, and the new debug category exposes the meta-commands
(n, s, c, Q, where, …) as proper verbs. Multi-agent safety via per-session
lock + tx transaction wrapper. MCP server mode exposes the entire
surface to Claude Code, Cline, Cursor, Continue and any other MCP client,
with embedded MCP-flavored agent guidance via initialize.instructions.
Every command runs by calling an rstudiocli
R function (companion package embedded in the binary, auto-installed on
first RPC; usable standalone from any R prompt). r send captures
stdout, messages and errors while keeping the code visible in the user's
R console — agents get the output, users see the execution. Automatic
update check (background, TTL 24 h): _update_available injected into
every MCP tool response; bare notice on stderr in CLI mode. Dedicated
project category for project lifecycle (new / init / clone / open /
current). 42 live integration tests across every category, runnable
against an open Desktop session or against a self-contained
rocker/rstudio Docker harness with headless Chromium (used by the
live CI job). Tested end-to-end on both RStudio Server (Linux)
and RStudio Desktop (macOS).
| category | actions | summary |
|---|---|---|
editor |
open edit close reload save save-all read read-buffer context insert select list new active-id path set-contents modify-range set-cursor set-marks |
Source pane and document operations |
r |
exec send poll kill interrupt |
Run R code silently, or visibly with captured output; async via callr; stop running R |
console |
history actions context activate |
Console history + buffer + live editor context |
debug |
status step where locals src exit |
R debugger introspection (browser/debug/recover) and navigation |
term |
list buffer context create send exec kill clear activate busy running exit-code visible run |
Terminal pane (live shells) |
env |
list contents info |
Live R environment inspection |
pane |
viewer files markers preview-rd preview-sql preview preview-md preview-rmd preview-qmd save-plot highlight-ui |
Non-editor panes |
project |
current open new init clone |
Project lifecycle: create / init / clone / open / introspect |
session |
info restart list |
Whole-session lifecycle |
pref |
read write read-rstudio write-rstudio get-persistent set-persistent |
Preferences + persistent values |
job |
list add remove set-progress add-progress set-state set-status add-output run-script kill is-active |
Background Jobs pane |
ui |
dialog update-dialog prompt question select-file select-dir ask-password ask-secret |
Modal prompts (BLOCKING) |
observe |
stream events replay |
Live JSONL stream; event-type catalog; replay a captured stream |
policy |
show block unblock |
Per-user block list (category or action) |
skill |
show install |
Embedded Claude Code skill |
schema |
(drill-down catalog) | Self-describing surface |
| escape | rpc postback |
Raw JSON-RPC / postback |
| meta | version status tx mcp |
Meta-CLI commands (no rsession schema entry) |
Run rstudio schema for the auto-generated catalog of every action.
Several tools let an LLM agent interact with R or RStudio. Here is an honest, best-effort comparison as of May 2026. If we have misrepresented what another tool can do, please open an issue and we will correct it promptly.
✓ supported · ~ partial or indirect · ✗ not supported · — not applicable
| Feature | rstudio‑cli | clauder | rstudiomcp | mcptools | Rstudio‑mcp |
|---|---|---|---|---|---|
| Transport & architecture | |||||
| Direct socket — no HTTP server, no open port | ✓ | ✗ | ✗ | ✗ | ✗ |
| Zero runtime dependency (single static binary) | ✓ | ✗ | ✗ | ✗ | ✗ |
| Runs from any external terminal, outside RStudio | ✓ | ✗ | ✗ | ✗ | ✗ |
Native remote/container transport (--via / config, no wrapper) |
✓ | ✗ | ✗ | ✗ | ✗ |
| Homebrew or binary install, no R/Python required | ✓ | ✗ | ✗ | ✗ | ✗ |
| Embedded R companion package, auto-installed on first RPC | ✓ | ✗ | ✗ | ✗ | ✗ |
| R package also usable standalone from any R prompt | ✓ | ✗ | ✗ | ✗ | ✗ |
| Platform | |||||
| RStudio Server (Linux) | ✓ | ✓ | ✓ | ✓ | ✓ |
| RStudio Desktop (macOS) — documented & tested | ✓ | ~ | ~ | ~ | ~ |
| AI integration | |||||
| Claude Code | ✓ | ✓ | ✓ | ✓ | ✓ |
| Cursor / Cline / VS Code Copilot | ✓ | ✓ | ✓ | ✓ | ✓ |
| Native MCP server (stdio) | ✓ | ✓ | ✓ | ✓ | ✓ |
| Usable by any shell, script or Makefile (no MCP) | ✓ | ✗ | ✗ | ✗ | ✗ |
| Self-describing schema with lazy drill-down | ✓ | ✗ | ✗ | ✗ | ✗ |
Full rstudioapi traceability per action |
✓ | ✗ | ✗ | ✗ | ✗ |
| R execution | |||||
| Evaluate code, capture output | ✓ | ✓ | ✓ | ✓ | ✓ |
| Send code to the visible R console | ✓ | ✓ | ✗ | ✗ | ✗ |
| Send visible code AND capture its output | ✓ | ✗ | ✗ | ✗ | ✗ |
| Browser-aware code execution (auto-targets debug frame) | ✓ | ✗ | ✗ | ✗ | ✗ |
Per-response eval_env field (which scope ran the code) |
✓ | ✗ | ✗ | ✗ | ✗ |
Dedicated debug verbs (step n, where, locals, …) |
✓ | ✗ | ✗ | ✗ | ✗ |
| Configurable per-call timeout | ✓ | ✗ | ✗ | ✗ | ✓ |
Structured error kinds (r_error, timeout, …) |
✓ | ✗ | ✗ | ✗ | ✗ |
| Async R subprocess (long-running, non-blocking, via callr) | ✓ | ✓ | ✗ | ✗ | ✗ |
| Environment inspection | |||||
| List R objects | ✓ | ✓ | ✓ | ✓ | ✓ |
| Object type, class, structure detail | ✓ | ✓ | ✓ | ~ | ✓ |
| Pattern filter on object list | ✓ | ✗ | ✗ | ✗ | ✗ |
| Multi-session support (list + target by socket) | ~ | ✓ | ✗ | ✗ | ✗ |
| Editor | |||||
| Open / close / save / reload documents | ✓ | ✗ | ~ | ✗ | ✗ |
| Read document content | ✓ | ~ | ~ | ✗ | ✗ |
| Insert text / replace text ranges | ✓ | ~ | ~ | ✗ | ✗ |
| Set cursor position | ✓ | ✗ | ✗ | ✗ | ✗ |
| Operate on any open document (not only the active one) | ✓ | ✗ | ✗ | ✗ | ✗ |
| Read arbitrary disk files (paginated) | ~ | ✓ | ✗ | ✗ | ✗ |
| Search project source files (regex) | ~ | ✓ | ✗ | ✗ | ✗ |
| Pipe grep/rg hits to IDE Markers pane | ✓ | ✗ | ✗ | ✗ | ✗ |
| Visualizations & panes | |||||
| Capture current plot | ✓ | ✓ | ✓ | ✗ | ✓ |
| Read Viewer pane HTML content | ✓ | ✓ | ✓ | ✗ | ✗ |
| Render Markdown / Rmd / Quarto → Viewer pane | ✓ | ✗ | ✗ | ✗ | ✗ |
| Surface lint markers | ✓ | ✗ | ✗ | ✗ | ✗ |
| Terminal pane | |||||
| List / create / kill terminals | ✓ | ✗ | ✗ | ✗ | ✗ |
| Read terminal buffer | ✓ | ✗ | ✗ | ✗ | ✗ |
| Send keys / run shell commands | ✓ | ✗ | ✗ | ✗ | ✗ |
| Background Jobs pane | |||||
| List / add / remove jobs | ✓ | ✗ | ✗ | ✗ | ✗ |
| Progress tracking (units, state, output stream) | ✓ | ✗ | ✗ | ✗ | ✗ |
| Run an R script as a background job | ✓ | ✗ | ✗ | ✗ | ✗ |
| Stop a running job / interrupt the R console | ✓ | ✗ | ✗ | ✗ | ✗ |
| Modal UI | |||||
| Dialog / prompt / question modals | ✓ | ✗ | ✗ | ✗ | ✗ |
| Password / secret prompts | ✓ | ✗ | ✗ | ✗ | ✗ |
| File / directory picker | ✓ | ✗ | ✗ | ✗ | ✗ |
| Preferences & session | |||||
| Read / write RStudio preferences | ✓ | ✗ | ✗ | ✗ | ✗ |
| Persistent key-value store | ✓ | ✗ | ✗ | ✗ | ✗ |
| Open / restart project | ✓ | ✗ | ✗ | ✗ | ✗ |
| Package & project (beyond rstudioapi) | |||||
| Install / update R packages | ✗ | ✗ | ✗ | ✗ | ✓ |
| Git integration | ✗ | ✗ | ✗ | ✗ | ✓ |
| Observability (live JSONL stream) | |||||
| Document open / close / save / dirty / typing (no R) | ✓ | ✗ | ✗ | ✗ | ✗ |
| Console.input stream with RStudio-authoritative timestamp | ✓ | ✗ | ✗ | ✗ | ✗ |
| rsession.error + project / markers / files / find / pane events | ✓ | ✗ | ✗ | ✗ | ✗ |
| R busy / idle, env, wd, attached pkgs, namespaces (Tier 2) | ✓ | ✗ | ✗ | ✗ | ✗ |
| Typed env, last_value, plot count (Tier 3) | ✓ | ✗ | ✗ | ✗ | ✗ |
Causal ordering: effects buffered until cause console.input lands |
✓ | ✗ | ✗ | ✗ | ✗ |
| Multi-agent safety | |||||
| OS-enforced per-session writer mutex (flock) | ✓ | ✗ | ✗ | ✗ | ✗ |
Multi-call atomicity (tx -- <cmd>, fork-inherit) |
✓ | ✗ | ✗ | ✗ | ✗ |
Multi-call atomicity in MCP (tx_begin / tx_end / tx_run tools) |
✓ | ✗ | ✗ | ✗ | ✗ |
| Lock-holder attribution (PID + command + timestamp) | ✓ | ✗ | ✗ | ✗ | ✗ |
| Cross-surface lock (CLI agents and MCP agents share the same flock) | ✓ | ✗ | ✗ | ✗ | ✗ |
| Multi-agent collaborative protocol (LLM convention) | ✗ | ✓ | ✗ | ✗ | ✗ |
| Safety & output | |||||
client_init blacklisted (session cannot be stolen) |
✓ | — | — | — | — |
Block dangerous R calls (system, unlink, …) |
✗ | ✓ | ✗ | ✗ | ✓ |
| Persistent CLI-level block list (category or action) | ✓ | ✗ | ✗ | ✗ | ✗ |
| Per-agent execution audit log | ✗ | ✓ | ✗ | ✗ | ✗ |
| Structured JSON output with typed error envelope | ✓ | ✗ | ✗ | ✗ | ✗ |
| Automatic update notification (CLI stderr + MCP tool response) | ✓ | ✗ | ✗ | ✗ | ✗ |
Notes on ~:
- rstudio‑cli / disk files & search: by design, rstudio-cli does not duplicate
grep/rg. Search is left to the agent or shell;editor set-marksthen surfaces the results in the IDE. - rstudio‑cli / multi-session:
session listenumerates Server sockets; switching is done via--socket. Desktop multi-process listing not yet supported. - clauder / editor: read and edit are limited to the currently active document.
- rstudiomcp / editor: open and create work; close/save are not exposed; read and edit are active-document only.
- RStudio Desktop / clauder, rstudiomcp, mcptools: these packages use
rstudioapiinternally, which works on Desktop, but Desktop support is not explicitly documented or tested by those projects. - Rstudio‑mcp / Desktop: external Python server; Desktop connectivity is undocumented.
Unix composability over duplication. The CLI does not re-implement
tools that already exist. File search is a prime example: grep, rg,
ag, and any LLM agent's own file-reading tools do this better than any
wrapper ever could. What the CLI adds — and what no shell tool provides
— is the RStudio UI integration. editor set-marks therefore reads from
stdin in standard grep format and populates the Markers pane; the search
itself is left to the best available tool:
# agent or shell already knows how to search:
grep -rn "TODO" src/ --include="*.R" | rstudio editor set-marks
rg --vimgrep "FIXME" . | rstudio editor set-marks --type warningThis is why editor find does not exist: it would duplicate grep
without adding value, and violate the single-responsibility principle
that makes Unix pipelines composable.
Every CLI command runs by calling a function in the rstudiocli R
package shipped inside the binary (source tree under r-package/,
embedded via include_bytes! at compile time, auto-installed into the
user's R library on the first RPC — silent, idempotent). One source of
truth for the R-side surface, used by:
- the CLI (
rstudio editor open→rstudiocli::editor_open(...)), - the MCP server (every tool call routes through the same wrappers),
- human R sessions (
library(rstudiocli); editor_set_contents(...)works directly from any R prompt — Desktop or Server), - and the
r_scriptMCP tool (orchestrate multiple actions in one R script, return only the final value to the agent).
Roxygen-documented, testthat-tested, R CMD check clean. Wraps ~50 of
the 117 functions in rstudioapi, with AI-native ergonomics:
structured list() returns, robust id/path resolution, uniform
errors via stop().
Two configuration knobs (both optional, sensible defaults):
# Throttle between UI-mutating ops. Gives the GWT client time to
# acknowledge each event before the next call lands. Default 500 ms.
options(rstudiocli.throttle_ms = 200) # snappier; risk: event saturation
options(rstudiocli.throttle_ms = 0) # disabled; only safe on Desktop
# Same setting via env var, for non-interactive use (CI, container, …)
Sys.setenv(RSTUDIOCLI_THROTTLE_MS = "200")AI-native pattern
This CLI follows the AI-native CLI pattern — embedded skill, schema drill-down, JSON envelope. The design rationale and the wider landscape (Google Workspace CLI, Linearis, prior writings on the term) live in the spec.
The CLI ships an embedded skill markdown. An LLM agent loads only the skill, then discovers the surface on demand:
rstudio schema # level 0: every action with category + summary
rstudio schema editor # level 1: actions in 'editor'
rstudio schema editor open # level 2: full ActionSpec (params, examples, errors,
# rstudioapi_fn, rpc_method)Each level-2 entry traces back to its rstudioapi function and the
JSON-RPC method (or postback) used, so the contract is fully
discoverable without reading the source code.
rstudio skill install # writes ./.claude/skills/rstudio/SKILL.md
rstudio skill show # prints the embedded skill markdown
rstudio version # 0.21.4This keeps the agent's context window lean — no tool descriptions are loaded for actions the agent never uses in a session.
The CLI runs on the same machine as RStudio (same Unix user as
the rsession process). Where exactly it runs is flexible:
- RStudio Server (Linux). Inside a session's embedded terminal
works (env vars are pre-set), but any shell on the same host
works too — the CLI scans
$RS_SESSION_TMP_DIRand picks the rsession socket owned by your uid. A browser tab must be attached to the session (the CLI reads the active client id from on-disk state and never callsclient_init, which would invalidate that client). If you have multiple sessions, the scan returns an actionable error listing each candidate as--socket <path>. - RStudio Desktop (macOS). Any terminal works as the user who
launched RStudio. The CLI auto-discovers the rsession process
(TCP port + shared secret from argv/environ); pass
--port/--secretto override.
Linux Desktop and Windows are out of scope.
brew install aclemen1/tap/rstudio-cli
rstudio versionBuilds are attached to each GitHub Release
for four targets: x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu,
x86_64-apple-darwin, aarch64-apple-darwin.
# Replace VERSION and TARGET as needed
curl -sL "https://github.com/aclemen1/rstudio-cli/releases/download/vVERSION/rstudio-cli-vVERSION-TARGET.tar.gz" \
| tar -xzC ~/.local/bin
chmod +x ~/.local/bin/rstudio
rstudio versioncargo install --git https://github.com/aclemen1/rstudio-cli rstudio-cli
# or
git clone https://github.com/aclemen1/rstudio-cli && cd rstudio-cli
cargo build --release && cp target/release/rstudio ~/.local/bin/rstudio skill install # ./.claude/skills/rstudio/SKILL.mdThe skill is for shell-driven agents — it teaches a Claude Code (or
any LLM that sees the user's filesystem) how to use the rstudio CLI
from a Bash context (pipes, --format, rstudio tx -- bash, etc.).
For agents that prefer native tool integration over shell
invocations, rstudio can run as an MCP (Model Context Protocol)
server over stdio. The agent's MCP client spawns the server, lists
its tools, and invokes them like any other native tool — no shell
quoting, no JSON parsing, automatic schema validation.
Claude Code (CLI):
claude mcp add rstudio --scope user -- rstudio mcpClaude Desktop — edit
~/Library/Application Support/Claude/claude_desktop_config.json
(macOS) or the equivalent on Linux:
{
"mcpServers": {
"rstudio": {
"command": "rstudio",
"args": ["mcp"]
}
}
}Cline / Continue / Cursor — most MCP-aware extensions accept the
same command + args shape in their settings panel. Refer to the
extension's docs; the entry point is always rstudio mcp.
What you get after configuration: the LLM sees ~90 tools in its
catalog (editor_open, editor_read_buffer, r_exec, meta_status,
…). Plus three transactional control tools — tx_begin, tx_end,
tx_run — for atomic multi-call sequences. The MCP server's
initialize response carries an embedded skill (cross-cutting agent
guidance: defensive tx rule, hard constraints, R FIFO semantics)
that clients inject into the LLM's context automatically.
MCP design choices. The server follows the patterns David Soria Parra (Anthropic) summarised in The Future of MCP:
- Progressive discovery.
tools/listexposes only a minimal bootstrap surface (meta_status,meta_version,tx_*,r_script,tools_search). The ~90 actions in the catalog are reached viatools_search({category, action}), which returns the fullActionSpec+input_schema+ themcp_tool_nameto plug intotools/call. Keeps the LLM context lean: the agent loads only what it actually needs. - Tool composition over chatty round-trips.
r_scriptaccepts an R script that orchestrates several actions internally; only the final value crosses back to the agent. Intermediate buffers / environment dumps never enter the context window. - Atomicity is a first-class tool.
tx_begin/tx_end(and the shortcuttx_run) wrap multi-call sequences so concurrent agents serialise correctly. Errors auto-release the lock.
Verify the server runs:
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
| rstudio mcp \
| jq '.result | {protocolVersion, server: .serverInfo.name, tools_promised: .capabilities.tools}'You can run the CLI and the MCP server simultaneously — they
share the same per-session writer lock, so a Claude Code MCP server,
a Cline MCP server, and a shell rstudio editor write invocation
all serialise correctly via the kernel without any manual
coordination.
rstudio mcp talks to the rsession over a Unix socket under
/var/run/rstudio-server/, so the binary must run on the same
host/container as the rsession. When the rsession lives in a remote
container (Kubernetes pod, Docker container, SSH host), you don't ship
sockets across the network — you wrap rstudio mcp in a transport
that relays stdin/stdout verbatim, and the MCP client on your laptop
speaks JSON-RPC straight through it.
Native transport with --via (recommended when rstudio is
installed locally). Instead of a wrapper script or a verbose
.mcp.json, give rstudio mcp the transport prefix and it execs
<prefix> rstudio mcp --no-via for you, relaying stdio:
{
"mcpServers": {
"rstudio": {
"type": "stdio",
"command": "rstudio",
"args": ["mcp", "--via", "docker compose exec -T -u ds -e USER=ds -w /home/ds/project ide"]
}
}
}Or keep .mcp.json as plain rstudio mcp and put the prefix in a
per-project .rstudio-cli.toml at the repo root — the same file then
works whether the agent runs on the laptop or inside the container:
[mcp]
via = "docker compose exec -T -u ds -e USER=ds -w /home/ds/project ide"
via_unless_local = "server"A user-level default is read from <config-dir>/rstudio-cli/config.toml
($XDG_CONFIG_HOME/rstudio-cli/config.toml on Linux). Precedence,
highest first: --via <prefix> (or --no-via / --via "" to force
local), the project .rstudio-cli.toml, then the user file.
via_unless_local makes the configured via a fallback: if a local
rsession is already reachable, serve it and skip the tunnel; tunnel only
when nothing local answers. Set it whenever the same repo (and the same
committed config) is also opened inside the container — e.g. an agent
launched from the RStudio terminal runs a plain rstudio mcp and would
otherwise read via and try to docker its way out of a place that has
no docker. On the host there is no local session, so it tunnels; inside
the container the local rsession answers, so it serves local.
Its value chooses what counts as local:
"server"— only a local RStudio Server socket counts. Use this for a project whose session lives in a container. A RStudio Desktop running on your host does not count, so the host still tunnels to the container instead of silently serving Desktop.true— any local session counts, Desktop included. If Desktop is open on your machine it is served; only choose this when that is what you want."desktop"— only a local Desktop counts (the mirror case).
Leave via_unless_local off (the default) and a config via always
tunnels. An explicit --via is always unconditional; --no-via always
forces local.
Version note. The string values ("server" / "desktop") need
rstudio-cli 0.21.2 or newer on every side that reads the config —
including the binary inside the container, since it reads the same
committed .rstudio-cli.toml. A 0.21.1 binary accepts only the boolean
form and silently treats a string as off (so it tunnels and, inside a
container with no docker, fails); a pre-0.21.0 binary ignores the file
entirely. Keep the in-container binary current (a Homebrew bootstrap
that installs the latest is the simplest). An unknown value (a typo) is a
hard config error: the MCP server refuses to start rather than guess a
scope and risk serving the wrong session — the error names
via_unless_local and the file.
Two re-entrancy guards keep the tunnel from looping back on itself.
Against the tunnel re-entering its own exec, --via appends --no-via
to the remote command — a command-line flag, not an environment
variable, because docker exec and ssh do not forward environment by
default. It is appended only when the remote is 0.21.0 or newer: --via
first probes with <prefix> rstudio version, so an older remote binary
(which does not understand --no-via, and never reads the config file,
so cannot loop) is not broken — no version-ordering dance when
upgrading. Against a fresh in-container rstudio mcp from the same
config, use via_unless_local above. The same three operational rules
below still apply to the prefix (no PTY, -u <user>, -e USER=<user>).
--via needs the rstudio binary on the client machine (to bootstrap
the exec). When the client has no local binary, use one of the manual
transports below, which run entirely through kubectl / docker /
ssh.
kubectl exec -i is the canonical manual example. Drop this into your
client's .mcp.json (or equivalent) and the JSON-RPC frames flow
through the tunnel unchanged — handshake, tool calls, notifications,
all of it:
{
"mcpServers": {
"rstudio-remote": {
"type": "stdio",
"command": "kubectl",
"args": [
"exec", "-i",
"-n", "<NAMESPACE>",
"--context", "<KUBE_CONTEXT>",
"deployment/<DEPLOYMENT>",
"-c", "<CONTAINER>",
"--",
"sh", "-c",
"USER=<RSTUDIO_USER> exec /home/linuxbrew/.linuxbrew/bin/rstudio mcp"
]
}
}
}Four non-obvious constraints that fail silently if you miss them:
- No PTY. Use
kubectl exec -ionly — never-t/-it. A pseudo-TTY injects terminal control sequences that corrupt JSON-RPC framing. Symptom: theinitializehandshake fails or the stream desyncs erratically. Same rule for any transport:docker execwithout-t,sshwithout-t. - Export
USERexplicitly.kubectl exec(and most non-loginexecs) do not propagate$USER.rstudio mcpneeds it to locate the rsession; without it you get acannot determine usererror. A login shell alone is not enough — setUSER=<rstudio_user>inline beforeexec rstudio mcp. - Absolute path to the binary. Non-login
execdoesn't inherit the enrichedPATH(linuxbrew, etc.), so a barerstudiowon't resolve. Use the full path (e.g./home/linuxbrew/.linuxbrew/bin/rstudio). - Target a deployment, not a pod. Pods are ephemeral; pinning
deployment/<name>(or a stable service) lets the wiring survive pod restarts. An in-flight stdio connection still drops when the pod is recreated — the client just has to reconnect the server (in Claude Code:/mcp→ Reconnect).
End-to-end smoke test — one handshake is enough to validate the whole chain:
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}' \
| kubectl exec -i -n <NAMESPACE> --context <KUBE_CONTEXT> \
deployment/<DEPLOYMENT> -c <CONTAINER> -- \
sh -c 'USER=<RSTUDIO_USER> exec /home/linuxbrew/.linuxbrew/bin/rstudio mcp' \
| jq '.result.serverInfo.name'
# → the rstudio-cli server name, no error.Docker Compose. The native .rstudio-cli.toml above already gives
you one committed .mcp.json (rstudio mcp) that works both on the
host and inside the container. If the client has no local rstudio
binary and you still want a single .mcp.json, a switch script does
the same job in pure shell — direct inside the container, through
Compose otherwise:
#!/bin/sh
# .mcp/rstudio-mcp.sh — one entry point, host or container.
set -eu
if [ -f /.dockerenv ]; then
exec rstudio mcp "$@" # already inside the container
fi
cd "$(dirname "$0")/.." # repo root, where compose.yaml lives
exec docker compose exec -T -u ds -e USER=ds -w /home/ds/project ide \
rstudio mcp "$@"Either way, docker compose exec carries one operational rule beyond
the two below (each learned from a silent failure):
-T— no PTY, for the reason in constraint 1.docker compose execallocates a TTY by default;-Tdisables it. Without it the JSON-RPC stream is corrupted.-u <user>— run as the session user. The rsession socket is owned by that user only; the service's default user (often root) cannot reach it. Symptom:no … rsession found.-e USER=<user>—docker execdoes not set$USER, and rstudio-cli derives the session identity from it. Without it:cannot determine user.
SSH. Same shape, one line — ssh allocates no PTY without -t, so
constraint 1 is satisfied by default:
ssh <user>@<host> rstudio mcpPut that as command: "ssh", args: ["<user>@<host>", "rstudio mcp"]
in .mcp.json. A login shell sets $USER and PATH, so constraints 2
and 3 usually take care of themselves; if rstudio is not on the login
PATH, use its absolute path.
Other transports. The recipe isn't kubectl-specific: anything that
launches rstudio mcp in the rsession's context and shuttles
stdin/stdout without a PTY works the same way — docker exec -i,
ssh <host> -- sh -c '…', nsenter, etc. Constraints 2 and 3
(explicit USER, absolute binary path) hold for every transport;
constraint 1 generalises to "do not allocate a TTY" on whatever tool
you're using.
First check: meta_status (or rstudio status). Run it before
anything else — it confirms the tunnel reaches a live rsession. A
freshly started container has no rsession until an RStudio browser
tab has been opened at least once since it started; the socket is
created only when a client authenticates. Until then every call fails
with no … rsession found. Open the RStudio web UI once to spawn the
rsession, then reconnect the MCP server.
After editing .mcp.json the client must (re)connect the server
to pick up the change — in Claude Code, /mcp → Reconnect. Same
operation after a pod is recreated.
This pattern is in production at UNIL/UNISIS (project endreas),
where each developer's R/RStudio devspace runs in a Kubernetes pod
and a local Claude Code drives it through exactly this .mcp.json
shape (auto-generated per devspace).
The CLI reads the following at each invocation:
| Var | Purpose | Fallback |
|---|---|---|
$RSTUDIO_SESSION_STREAM |
Unix socket name under $RS_SESSION_TMP_DIR |
scan $RS_SESSION_TMP_DIR for a socket owned by the current uid; single match wins, error otherwise |
$RS_SESSION_TMP_DIR |
Directory holding the rsession socket | /var/run/rstudio-server/rstudio-rsession |
$RSTUDIO_SESSION_ID |
Used to find session-persistent-state for clientId |
most-recently-modified session-* under ~/.local/share/rstudio/sessions/active |
$USER |
Identity sent in X-RStudioUserIdentity |
$LOGNAME |
Override any of them via --socket, --user, --session-id, --state-path.
JSON by default. Pass --format text for human-readable output where
sensible (output strings, lines / commands arrays).
{ "ok": true, "result": { "..." : "..." } }
{ "ok": true }
{ "ok": false, "error": { "code": 0, "kind": "session_unavailable", "message": "..." } }error.kind is one of: user_error, r_error, rpc_error,
timeout, session_unavailable, internal.
Exit codes: 0 success, 1 runtime error, 2 bad CLI args.
# Open a file at line 42 in the user's editor.
rstudio editor open src/main.rs --line 42
# Evaluate R silently and read the captured output.
rstudio r exec 'summary(mtcars)'
# Bypass the 2 s server limit for a longer job.
rstudio r exec --timeout 60 'Sys.sleep(10); summary(mtcars)'
rstudio r exec --timeout 0 'long_running()' # no limit
# Send code to the visible R console and capture its output.
# Returns {stdout, messages, error}; user sees ℝ(~{ … }) in their console.
rstudio r send 'summary(mtcars)'
rstudio r send --no-capture 'print(Sys.time())' # fire-and-forget
# Spawn a shell command in a fresh terminal and watch its output.
ID=$(rstudio --format text term run 'cargo build' --working-dir ~/projects/foo | jq -r .id)
sleep 5; rstudio term buffer "$ID" --limit 30
# Surface lint-style feedback in the Markers pane.
rstudio pane markers --name 'lint' --markers '[
{"type":"warning","file":"/path/to/foo.R","line":12,"message":"Unused variable"}
]'
# Inspect the active R environment.
rstudio env list --pattern '^df_'
rstudio env info mtcars
# Background job.
JOB=$(rstudio --format text job add --name 'indexing' --progress-units 100 --running | jq -r .id)
rstudio job set-progress "$JOB" 50
rstudio job set-state "$JOB" succeeded
# Whole-session info on startup.
rstudio session infoThree tiers:
cargo test --lib # 97 unit tests (~0.3 s, no R needed)
cargo test --tests # +31 non-live integration tests (~5 s)
cargo test --test live -- --ignored # +42 live tests against a real rsessionThe live tests skip silently (SKIP: no live RStudio session available)
when no rsession socket is reachable. They cover every CLI command
category (status, session, project, pref, pane, job, term, editor, env,
console, r exec, r send) and serialise through a process-wide mutex so
they never contend on the Desktop rsession socket. R-side state is
torn down at the end of each test.
For a clean-room run of the live tests without an open RStudio Desktop
session, scripts/bridge-up.sh brings up a self-contained
rocker/rstudio:4.5.2 container with headless Chromium, compiles the
test binary inside, and runs the full suite against the in-container
rsession:
scripts/bridge-up.sh up # one-time setup (~3 min on a cold pull)
scripts/bridge-up.sh test-live # sync + build + run 42 live tests (~2 min)
scripts/bridge-up.sh test-all # fmt + clippy + every test binary (~3 min)
scripts/bridge-up.sh down # stop the container (cargo cache kept)This is the same harness the live job in .github/workflows/ci.yml
runs on every PR and every push to main.
R is single-threaded; the rsession serialises every r exec (and any
other execute_r_code-based call) into a FIFO. Two concurrent calls
do not run in parallel — total wall time ≈ sum of per-call time.
--timeout 0on a longr execblocks every subsequentr-style call until it returns. Use deliberately.- For real parallelism (running shell commands or external processes),
use
term exec/term run— the Terminal pane spawns a separate pty/process, not bound to the R FIFO. - Postbacks (
editor edit) andconsole_input(r send) don't go through the R queue, so they aren't subject to the FIFO.
Two agents (or one agent + one script) running rstudio against the
same session can interleave their writes in surprising ways. The CLI
defends against this in two layers:
Per-call mutex (Phase 1, transparent). Every write command — r exec, editor write, pref write, ui *, session restart, etc.
— acquires an exclusive flock on
~/.config/rstudio-cli/locks/session-<id>.lock for the duration of
the call. Reads (editor list, env list, console history,
observe stream, schema, version, …) take no lock — the rsession
already serialises its own RPCs, so reader-writer races aren't a
protocol hazard. Holder attribution (PID + command + timestamp) is
written to a sidecar JSON, surfaced in the timeout error.
Multi-call atomicity via rstudio tx (Phase 2). For sequences of
operations that must be atomic across multiple invocations (e.g.
read-buffer X → transform → set-contents X), wrap the sequence in
a child process. tx -- holds the lock for the lifetime of the child
and sets RSTUDIO_TX_HELD=1 so that every nested rstudio call
inside skips its own per-call lock (the parent already holds it).
Patterned after flock(1) from util-linux:
# Atomic read-modify-write
rstudio tx -- bash -c '
buf=$(rstudio editor read-buffer X | jq -r .result.contents)
new=$(printf "%s" "$buf" | sed "s/foo/bar/g")
rstudio editor set-contents X "$new"
'
# Interactive REPL inside a transaction
rstudio tx -- bash
# Default: $SHELL with the lock held
rstudio txKernel cleanup: when the holding process exits — cleanly, on kill -9, or on crash — the OS releases the flock automatically. No PID
files, no stale locks, no daemon. Bypass with the global --no-lock
flag for power users.
rstudio rpc client_initis blacklisted at the CLI level — calling it invalidates the active browser client and resets the user's RStudio session.- Avoid
connection_test(raw RPC) for anything that can throw: R errors raised through it leak into the user's visible console. Therandeditorwrappers route throughexecute_r_codeinstead, which is silent.
MIT — see LICENSE.