clauder-mcp 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ .DS_Store
2
+ *.mov
3
+ .Rproj.user
4
+ ClaudeR.Rproj
5
+ .Rhistory
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: clauder-mcp
3
+ Version: 0.2.0
4
+ Summary: MCP server connecting AI assistants to RStudio for interactive R coding and data analysis
5
+ Author: Nykko Vitali
6
+ License: MIT
7
+ Keywords: data-science,llm,mcp,r,rstudio
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: httpx
10
+ Requires-Dist: mcp>=1.0.0
11
+ Description-Content-Type: text/markdown
12
+
13
+ # clauder-mcp
14
+
15
+ MCP server that connects AI assistants to RStudio for interactive R coding and data analysis.
16
+
17
+ This is the Python MCP bridge component of [ClaudeR](https://github.com/IMNMV/ClaudeR). It works with Claude Desktop, Claude Code, Codex CLI, Gemini CLI, Cursor, and any other MCP-compatible client.
18
+
19
+ ## Prerequisites
20
+
21
+ You need the ClaudeR R package installed and running in RStudio:
22
+
23
+ ```r
24
+ if (!require("devtools")) install.packages("devtools")
25
+ devtools::install_github("IMNMV/ClaudeR")
26
+ library(ClaudeR)
27
+ claudeAddin()
28
+ ```
29
+
30
+ Start the server from the RStudio Viewer pane before connecting your AI tool.
31
+
32
+ ## Usage
33
+
34
+ ### Claude Code
35
+
36
+ ```bash
37
+ claude mcp add --transport stdio --scope user r-studio -- uvx clauder-mcp
38
+ ```
39
+
40
+ ### Codex CLI
41
+
42
+ ```bash
43
+ codex mcp add r-studio -- uvx clauder-mcp
44
+ ```
45
+
46
+ ### Gemini CLI
47
+
48
+ Add to your `~/.gemini/settings.json`:
49
+
50
+ ```json
51
+ {
52
+ "mcpServers": {
53
+ "r-studio": {
54
+ "command": "uvx",
55
+ "args": ["clauder-mcp"]
56
+ }
57
+ }
58
+ }
59
+ ```
60
+
61
+ ### Claude Desktop / Cursor
62
+
63
+ Add to your MCP config file:
64
+
65
+ ```json
66
+ {
67
+ "mcpServers": {
68
+ "r-studio": {
69
+ "command": "uvx",
70
+ "args": ["clauder-mcp"]
71
+ }
72
+ }
73
+ }
74
+ ```
75
+
76
+ ## Tools
77
+
78
+ - **execute_r** - Execute R code and return output
79
+ - **execute_r_with_plot** - Execute R code that generates a plot
80
+ - **execute_r_async** - Run long-running code in a background process
81
+ - **get_async_result** - Poll for async job results
82
+ - **read_file** - Read any text file from disk
83
+ - **get_active_document** - Get the active document in RStudio
84
+ - **modify_code_section** - Edit code in the active document
85
+ - **get_r_info** - Get R environment information
86
+ - **list_sessions** - List available RStudio sessions
87
+ - **connect_session** - Connect to a specific session
88
+ - **get_session_history** - View execution history by agent
89
+ - **create_task_list** - Create a task list for analysis
90
+ - **update_task_status** - Update task progress
91
+
92
+ ## Links
93
+
94
+ - [ClaudeR R Package](https://github.com/IMNMV/ClaudeR) - Full documentation, installation guide, and features
95
+ - [License](https://github.com/IMNMV/ClaudeR/blob/main/LICENSE) - MIT
@@ -0,0 +1,83 @@
1
+ # clauder-mcp
2
+
3
+ MCP server that connects AI assistants to RStudio for interactive R coding and data analysis.
4
+
5
+ This is the Python MCP bridge component of [ClaudeR](https://github.com/IMNMV/ClaudeR). It works with Claude Desktop, Claude Code, Codex CLI, Gemini CLI, Cursor, and any other MCP-compatible client.
6
+
7
+ ## Prerequisites
8
+
9
+ You need the ClaudeR R package installed and running in RStudio:
10
+
11
+ ```r
12
+ if (!require("devtools")) install.packages("devtools")
13
+ devtools::install_github("IMNMV/ClaudeR")
14
+ library(ClaudeR)
15
+ claudeAddin()
16
+ ```
17
+
18
+ Start the server from the RStudio Viewer pane before connecting your AI tool.
19
+
20
+ ## Usage
21
+
22
+ ### Claude Code
23
+
24
+ ```bash
25
+ claude mcp add --transport stdio --scope user r-studio -- uvx clauder-mcp
26
+ ```
27
+
28
+ ### Codex CLI
29
+
30
+ ```bash
31
+ codex mcp add r-studio -- uvx clauder-mcp
32
+ ```
33
+
34
+ ### Gemini CLI
35
+
36
+ Add to your `~/.gemini/settings.json`:
37
+
38
+ ```json
39
+ {
40
+ "mcpServers": {
41
+ "r-studio": {
42
+ "command": "uvx",
43
+ "args": ["clauder-mcp"]
44
+ }
45
+ }
46
+ }
47
+ ```
48
+
49
+ ### Claude Desktop / Cursor
50
+
51
+ Add to your MCP config file:
52
+
53
+ ```json
54
+ {
55
+ "mcpServers": {
56
+ "r-studio": {
57
+ "command": "uvx",
58
+ "args": ["clauder-mcp"]
59
+ }
60
+ }
61
+ }
62
+ ```
63
+
64
+ ## Tools
65
+
66
+ - **execute_r** - Execute R code and return output
67
+ - **execute_r_with_plot** - Execute R code that generates a plot
68
+ - **execute_r_async** - Run long-running code in a background process
69
+ - **get_async_result** - Poll for async job results
70
+ - **read_file** - Read any text file from disk
71
+ - **get_active_document** - Get the active document in RStudio
72
+ - **modify_code_section** - Edit code in the active document
73
+ - **get_r_info** - Get R environment information
74
+ - **list_sessions** - List available RStudio sessions
75
+ - **connect_session** - Connect to a specific session
76
+ - **get_session_history** - View execution history by agent
77
+ - **create_task_list** - Create a task list for analysis
78
+ - **update_task_status** - Update task progress
79
+
80
+ ## Links
81
+
82
+ - [ClaudeR R Package](https://github.com/IMNMV/ClaudeR) - Full documentation, installation guide, and features
83
+ - [License](https://github.com/IMNMV/ClaudeR/blob/main/LICENSE) - MIT
@@ -0,0 +1,20 @@
1
+ [project]
2
+ name = "clauder-mcp"
3
+ version = "0.2.0"
4
+ description = "MCP server connecting AI assistants to RStudio for interactive R coding and data analysis"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ authors = [{ name = "Nykko Vitali" }]
8
+ keywords = ["r", "rstudio", "mcp", "llm", "data-science"]
9
+ license = { text = "MIT" }
10
+ dependencies = [
11
+ "mcp>=1.0.0",
12
+ "httpx",
13
+ ]
14
+
15
+ [project.scripts]
16
+ clauder-mcp = "clauder_mcp:main"
17
+
18
+ [build-system]
19
+ requires = ["hatchling"]
20
+ build-backend = "hatchling.build"
@@ -0,0 +1,11 @@
1
+ from .server import serve
2
+ import asyncio
3
+
4
+
5
+ def main():
6
+ """ClaudeR MCP Server - RStudio integration for AI assistants."""
7
+ asyncio.run(serve())
8
+
9
+
10
+ if __name__ == "__main__":
11
+ main()
@@ -0,0 +1,1060 @@
1
+ #!/usr/bin/env python3
2
+ # persistent_r_mcp.py
3
+
4
+ import argparse
5
+ import asyncio
6
+ import json
7
+ import tempfile
8
+ import os
9
+ import base64
10
+ import uuid
11
+ from typing import Any, Dict, List, Optional
12
+ import httpx
13
+ import sys
14
+ from datetime import datetime
15
+ from mcp.server import Server
16
+ from mcp.server.stdio import stdio_server
17
+ import mcp.types as types
18
+
19
+ # Configure the server instance
20
+ server = Server("r-studio")
21
+
22
+ # Configuration — overwritten in main() after arg parsing
23
+ R_ADDIN_URL = "http://127.0.0.1:8787" # Fallback if no discovery files found
24
+
25
+ # Session discovery
26
+ SESSIONS_DIR = os.path.expanduser("~/.claude_r_sessions")
27
+ _agent_id: Optional[str] = None # Set in main()
28
+ _target_session: Optional[str] = None # Set by connect_session tool
29
+ _agent_introduced: bool = False # First-call introduction flag
30
+
31
+ # Cache variable to store the result of the ggplot2 check
32
+ _is_ggplot_installed = None
33
+
34
+
35
+ def discover_sessions() -> List[Dict[str, Any]]:
36
+ """Read all discovery files and return list of available R sessions."""
37
+ sessions = []
38
+ if not os.path.isdir(SESSIONS_DIR):
39
+ return sessions
40
+ for f in os.listdir(SESSIONS_DIR):
41
+ if f.endswith(".json"):
42
+ try:
43
+ with open(os.path.join(SESSIONS_DIR, f)) as fh:
44
+ sessions.append(json.load(fh))
45
+ except Exception:
46
+ pass
47
+ return sessions
48
+
49
+
50
+ def get_r_addin_url() -> Optional[str]:
51
+ """Get the URL for the active R session via discovery files.
52
+ Falls back to hardcoded R_ADDIN_URL if no discovery files found."""
53
+ sessions = discover_sessions()
54
+ if len(sessions) == 0:
55
+ # Fallback to hardcoded default for backwards compatibility
56
+ return R_ADDIN_URL
57
+ if len(sessions) == 1:
58
+ return f"http://127.0.0.1:{sessions[0]['port']}"
59
+ # Multiple sessions — use _target_session if set
60
+ if _target_session:
61
+ for s in sessions:
62
+ if s["session_name"] == _target_session:
63
+ return f"http://127.0.0.1:{s['port']}"
64
+ # Default to first session
65
+ return f"http://127.0.0.1:{sessions[0]['port']}"
66
+
67
+
68
+ def parse_args():
69
+ parser = argparse.ArgumentParser(description="R Studio MCP Server")
70
+ parser.add_argument("--agent-id", type=str,
71
+ default=os.environ.get("CLAUDER_AGENT_ID", None),
72
+ help="Unique identifier for this agent instance")
73
+ return parser.parse_args()
74
+
75
+
76
+
77
+ async def check_ggplot_installed() -> bool:
78
+ """
79
+ Performs a one-time check to see if ggplot2 is installed in the R environment.
80
+ Caches the result for subsequent calls.
81
+ """
82
+ global _is_ggplot_installed
83
+ # If we've already checked, return the cached result immediately.
84
+ if _is_ggplot_installed is not None:
85
+ return _is_ggplot_installed
86
+
87
+ result = await execute_r_code_via_addin("print(requireNamespace('ggplot2', quietly = TRUE))")
88
+
89
+ if result.get("success") and "TRUE" in result.get("output", ""):
90
+ print("ggplot2 check successful.", file=sys.stderr)
91
+ _is_ggplot_installed = True
92
+ else:
93
+ print("ggplot2 not found in R environment.", file=sys.stderr)
94
+ _is_ggplot_installed = False
95
+
96
+ return _is_ggplot_installed
97
+
98
+ def escape_r_string(s: str) -> str:
99
+ """Escape special characters for safe inclusion in R double-quoted strings."""
100
+ s = s.replace("\\", "\\\\") # Backslashes first (order matters)
101
+ s = s.replace('"', '\\"') # Double quotes
102
+ s = s.replace("'", "\\'") # Single quotes
103
+ s = s.replace("`", "\\`") # Backticks (R evaluation)
104
+ s = s.replace("\n", "\\n") # Newlines
105
+ s = s.replace("\r", "\\r") # Carriage returns
106
+ s = s.replace("\t", "\\t") # Tabs
107
+ s = s.replace("\0", "") # Null bytes (strip entirely)
108
+ return s
109
+
110
+ # Function to execute R code via the HTTP addin
111
+ async def execute_r_code_via_addin(code: str) -> Dict[str, Any]:
112
+ """Execute R code through the RStudio addin HTTP server."""
113
+ url = get_r_addin_url()
114
+ if url is None:
115
+ return {
116
+ "success": False,
117
+ "error": "No R sessions found. Start the ClaudeR addin in RStudio first."
118
+ }
119
+ try:
120
+ payload: Dict[str, Any] = {"code": code}
121
+ if _agent_id:
122
+ payload["agent_id"] = _agent_id
123
+ async with httpx.AsyncClient() as client:
124
+ response = await client.post(
125
+ url,
126
+ json=payload,
127
+ timeout=120.0
128
+ )
129
+ response.raise_for_status()
130
+ return response.json()
131
+ except httpx.HTTPError as e:
132
+ print(f"HTTP error: {str(e)}", file=sys.stderr)
133
+ return {
134
+ "success": False,
135
+ "error": f"HTTP error communicating with RStudio: {str(e)}"
136
+ }
137
+ except Exception as e:
138
+ print(f"Error: {str(e)}", file=sys.stderr)
139
+ return {
140
+ "success": False,
141
+ "error": f"Error communicating with RStudio: {str(e)}"
142
+ }
143
+
144
+ async def post_to_r_addin(payload: Dict[str, Any]) -> Dict[str, Any]:
145
+ """Send an arbitrary JSON payload to the R addin HTTP server."""
146
+ url = get_r_addin_url()
147
+ if url is None:
148
+ return {"success": False, "error": "No R sessions found. Start the ClaudeR addin in RStudio first."}
149
+ try:
150
+ async with httpx.AsyncClient() as client:
151
+ response = await client.post(url, json=payload, timeout=10.0)
152
+ response.raise_for_status()
153
+ return response.json()
154
+ except Exception as e:
155
+ return {"success": False, "error": f"Error communicating with RStudio: {str(e)}"}
156
+
157
+
158
+ # Check if the R addin is running and return status info
159
+ async def check_addin_status(return_info: bool = False):
160
+ """Check if the RStudio addin is running.
161
+ If return_info is True, returns the full status dict or None.
162
+ Otherwise returns a bool."""
163
+ url = get_r_addin_url()
164
+ if url is None:
165
+ return None if return_info else False
166
+ try:
167
+ async with httpx.AsyncClient() as client:
168
+ response = await client.get(url, timeout=2.0)
169
+ if response.status_code == 200:
170
+ if return_info:
171
+ return response.json()
172
+ return True
173
+ except:
174
+ pass
175
+ return None if return_info else False
176
+
177
+
178
+ async def get_agent_introduction() -> str:
179
+ """Build a one-time context message for the agent's first tool call."""
180
+ info = await check_addin_status(return_info=True)
181
+
182
+ lines = [
183
+ f"[ClaudeR Agent Context]",
184
+ f"Your agent ID: {_agent_id}",
185
+ f"This ID uniquely identifies you in this R session. All code you execute is attributed to this ID.",
186
+ ]
187
+
188
+ if info:
189
+ other_agents = [a for a in info.get("connected_agents", []) if a != _agent_id and a != "unknown"]
190
+ if other_agents:
191
+ lines.append(f"Other agents active on this session: {', '.join(other_agents)}")
192
+ lines.append("These are other AI agents executing code in the same R environment. Coordinate to avoid conflicts.")
193
+
194
+ log_path = info.get("log_file_path")
195
+ if log_path:
196
+ lines.append(f"Session log file: {log_path}")
197
+ lines.append("This log contains all code executed by all agents. Read it to see what others have done.")
198
+
199
+ session_name = info.get("session_name", "unknown")
200
+ lines.append(f"Session: {session_name}")
201
+
202
+ lines.append("[End ClaudeR Agent Context]")
203
+ return "\n".join(lines)
204
+
205
+ # Define available tools
206
+ @server.list_tools()
207
+ async def list_tools() -> List[types.Tool]:
208
+ """List available R tools."""
209
+ return [
210
+ types.Tool(
211
+ name="execute_r",
212
+ description="Execute R code and return the output",
213
+ inputSchema={
214
+ "type": "object",
215
+ "properties": {
216
+ "code": {
217
+ "type": "string",
218
+ "description": "R code to execute. Avoid hardcoding values pulled from analyses. Always dynamically pull the value from the object or dataframe."
219
+ }
220
+ },
221
+ "required": ["code"]
222
+ },
223
+ annotations={
224
+ "readOnlyHint": False,
225
+ "destructiveHint": False,
226
+ "idempotentHint": False,
227
+ "openWorldHint": False,
228
+ }
229
+ ),
230
+ types.Tool(
231
+ name="execute_r_with_plot",
232
+ description="Execute R code that generates a plot",
233
+ inputSchema={
234
+ "type": "object",
235
+ "properties": {
236
+ "code": {
237
+ "type": "string",
238
+ "description": "R code to execute that generates a plot"
239
+ }
240
+ },
241
+ "required": ["code"]
242
+ },
243
+ annotations={
244
+ "readOnlyHint": False,
245
+ "destructiveHint": False,
246
+ "idempotentHint": False,
247
+ "openWorldHint": False,
248
+ }
249
+ ),
250
+ types.Tool(
251
+ name="get_r_info",
252
+ description="Get information about the R environment",
253
+ inputSchema={
254
+ "type": "object",
255
+ "properties": {
256
+ "what": {
257
+ "type": "string",
258
+ "description": "What information to get: 'packages', 'variables', 'version', or 'all'"
259
+ }
260
+ },
261
+ "required": ["what"]
262
+ },
263
+ annotations={
264
+ "readOnlyHint": True,
265
+ "destructiveHint": False,
266
+ "idempotentHint": True,
267
+ "openWorldHint": False,
268
+ }
269
+ ),
270
+ types.Tool(
271
+ name="get_active_document",
272
+ description="Get the content of the active document in RStudio",
273
+ inputSchema={
274
+ "type": "object",
275
+ "properties": {}
276
+ },
277
+ annotations={
278
+ "readOnlyHint": True,
279
+ "destructiveHint": False,
280
+ "idempotentHint": True,
281
+ "openWorldHint": False,
282
+ }
283
+ ),
284
+ types.Tool(
285
+ name="modify_code_section",
286
+ description="Modify a specific section of code in the active document",
287
+ inputSchema={
288
+ "type": "object",
289
+ "properties": {
290
+ "search_pattern": {
291
+ "type": "string",
292
+ "description": "Pattern to identify the section of code to be modified"
293
+ },
294
+ "replacement": {
295
+ "type": "string",
296
+ "description": "New code to replace the identified section"
297
+ },
298
+ "line_start": {
299
+ "type": "number",
300
+ "description": "Optional: Start line number for the search (1-based indexing)"
301
+ },
302
+ "line_end": {
303
+ "type": "number",
304
+ "description": "Optional: End line number for the search (1-based indexing)"
305
+ }
306
+ },
307
+ "required": ["search_pattern", "replacement"]
308
+ },
309
+ annotations={
310
+ "readOnlyHint": False,
311
+ "destructiveHint": True,
312
+ "idempotentHint": False,
313
+ "openWorldHint": False,
314
+ }
315
+ ),
316
+ types.Tool(
317
+ name="create_task_list",
318
+ description="Create a task list for the current analysis",
319
+ inputSchema={
320
+ "type": "object",
321
+ "properties": {
322
+ "tasks": {
323
+ "type": "array",
324
+ "items": {
325
+ "type": "object",
326
+ "properties": {
327
+ "id": {"type": "string"},
328
+ "description": {"type": "string"},
329
+ "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}
330
+ }
331
+ },
332
+ "description": "List of tasks to complete"
333
+ }
334
+ },
335
+ "required": ["tasks"]
336
+ },
337
+ annotations={
338
+ "readOnlyHint": False,
339
+ "destructiveHint": False,
340
+ "idempotentHint": False,
341
+ "openWorldHint": False,
342
+ }
343
+ ),
344
+ types.Tool(
345
+ name="update_task_status",
346
+ description="Update the status of a task and optionally add notes",
347
+ inputSchema={
348
+ "type": "object",
349
+ "properties": {
350
+ "task_id": {
351
+ "type": "string",
352
+ "description": "ID of the task to update"
353
+ },
354
+ "status": {
355
+ "type": "string",
356
+ "enum": ["pending", "in_progress", "completed"],
357
+ "description": "New status for the task"
358
+ },
359
+ "notes": {
360
+ "type": "string",
361
+ "description": "Optional notes about the task progress"
362
+ }
363
+ },
364
+ "required": ["task_id", "status"]
365
+ },
366
+ annotations={
367
+ "readOnlyHint": False,
368
+ "destructiveHint": False,
369
+ "idempotentHint": True,
370
+ "openWorldHint": False,
371
+ }
372
+ ),
373
+ types.Tool(
374
+ name="execute_r_async",
375
+ description="Execute long-running R code in a separate background R process. Returns a job ID immediately and the main session stays fully responsive. Use this for code that may take longer than 25 seconds (e.g., model fitting, simulations, large data processing). IMPORTANT: The background process does NOT have access to the main session's environment. Write self-contained code: use saveRDS() to pass data in and write results out, then load them back in the main session after the job completes. You can continue executing other code with execute_r while the job runs. Use get_async_result to check status when ready.",
376
+ inputSchema={
377
+ "type": "object",
378
+ "properties": {
379
+ "code": {
380
+ "type": "string",
381
+ "description": "R code to execute asynchronously"
382
+ }
383
+ },
384
+ "required": ["code"]
385
+ },
386
+ annotations={
387
+ "readOnlyHint": False,
388
+ "destructiveHint": False,
389
+ "idempotentHint": False,
390
+ "openWorldHint": False,
391
+ }
392
+ ),
393
+ types.Tool(
394
+ name="get_async_result",
395
+ description="Check the result of an async R job. Waits ~10 seconds before checking to avoid excessive polling. If the job is still running, call this again.",
396
+ inputSchema={
397
+ "type": "object",
398
+ "properties": {
399
+ "job_id": {
400
+ "type": "string",
401
+ "description": "The job ID returned by execute_r_async"
402
+ }
403
+ },
404
+ "required": ["job_id"]
405
+ },
406
+ annotations={
407
+ "readOnlyHint": True,
408
+ "destructiveHint": False,
409
+ "idempotentHint": True,
410
+ "openWorldHint": False,
411
+ }
412
+ ),
413
+ types.Tool(
414
+ name="list_sessions",
415
+ description="List available RStudio sessions that this agent can connect to. Shows session name, port, and PID for each active session.",
416
+ inputSchema={
417
+ "type": "object",
418
+ "properties": {}
419
+ },
420
+ annotations={
421
+ "readOnlyHint": True,
422
+ "destructiveHint": False,
423
+ "idempotentHint": True,
424
+ "openWorldHint": False,
425
+ }
426
+ ),
427
+ types.Tool(
428
+ name="connect_session",
429
+ description="Connect to a specific RStudio session by name. Use list_sessions first to see available sessions. Subsequent tool calls will be routed to this session.",
430
+ inputSchema={
431
+ "type": "object",
432
+ "properties": {
433
+ "session_name": {
434
+ "type": "string",
435
+ "description": "Name of the R session to connect to"
436
+ }
437
+ },
438
+ "required": ["session_name"]
439
+ },
440
+ annotations={
441
+ "readOnlyHint": False,
442
+ "destructiveHint": False,
443
+ "idempotentHint": True,
444
+ "openWorldHint": False,
445
+ }
446
+ ),
447
+ types.Tool(
448
+ name="read_file",
449
+ description="Read the contents of a file from disk. Use this to read R scripts, log files, data files (.csv, .txt), or any text file. The file does not need to be open in RStudio. Returns the file contents with line numbers. To modify and save changes back, use execute_r with writeLines().",
450
+ inputSchema={
451
+ "type": "object",
452
+ "properties": {
453
+ "file_path": {
454
+ "type": "string",
455
+ "description": "Path to the file to read. Supports absolute paths and ~ for home directory."
456
+ }
457
+ },
458
+ "required": ["file_path"]
459
+ },
460
+ annotations={
461
+ "readOnlyHint": True,
462
+ "destructiveHint": False,
463
+ "idempotentHint": True,
464
+ "openWorldHint": False,
465
+ }
466
+ ),
467
+ types.Tool(
468
+ name="get_session_history",
469
+ description="Get execution history for the current R session. Can filter by agent to see what a specific agent has done.",
470
+ inputSchema={
471
+ "type": "object",
472
+ "properties": {
473
+ "agent_filter": {
474
+ "type": "string",
475
+ "description": "Filter history by agent ID. Use 'self' for own history, 'all' for everything, or a specific agent ID."
476
+ },
477
+ "last_n": {
478
+ "type": "number",
479
+ "description": "Number of recent entries to return (default 20)"
480
+ }
481
+ }
482
+ },
483
+ annotations={
484
+ "readOnlyHint": True,
485
+ "destructiveHint": False,
486
+ "idempotentHint": True,
487
+ "openWorldHint": False,
488
+ }
489
+ ),
490
+ ]
491
+
492
+ @server.call_tool()
493
+ async def call_tool(name: str, arguments: Dict[str, Any]) -> List[types.TextContent | types.ImageContent]:
494
+ """Handle R tool calls."""
495
+ global _target_session, _agent_introduced
496
+
497
+ # These tools check Python-side state only — skip addin check
498
+ _skip_addin_check = {"list_sessions", "connect_session"}
499
+ if name not in _skip_addin_check:
500
+ # Check if the R addin is running
501
+ if not await check_addin_status():
502
+ return [types.TextContent(
503
+ type="text",
504
+ text="Error: RStudio addin is not running. Please start the Claude RStudio Connection addin in RStudio."
505
+ )]
506
+
507
+ result_contents = []
508
+
509
+ # First tool call: prepend agent context so the model knows its identity
510
+ if not _agent_introduced:
511
+ _agent_introduced = True
512
+ try:
513
+ intro = await get_agent_introduction()
514
+ result_contents.append(types.TextContent(type="text", text=intro))
515
+ except Exception:
516
+ pass # Don't block tool execution if introduction fails
517
+
518
+ if name == "execute_r":
519
+ if "code" not in arguments:
520
+ return [types.TextContent(
521
+ type="text",
522
+ text="Error: 'code' parameter is required"
523
+ )]
524
+
525
+ result = await execute_r_code_via_addin(arguments["code"])
526
+
527
+ if not result.get("success", False):
528
+ return [types.TextContent(
529
+ type="text",
530
+ text=f"R Error: {result.get('error', 'Unknown error')}"
531
+ )]
532
+
533
+ # Add text output
534
+ if "output" in result and result["output"]:
535
+ result_contents.append(types.TextContent(
536
+ type="text",
537
+ text=result["output"]
538
+ ))
539
+
540
+ # Add plot if available
541
+ if "plot" in result:
542
+ result_contents.append(types.ImageContent(
543
+ type="image",
544
+ data=result["plot"]["data"],
545
+ mimeType=result["plot"]["mime_type"]
546
+ ))
547
+
548
+ return result_contents or [types.TextContent(
549
+ type="text",
550
+ text="Code executed successfully but produced no output."
551
+ )]
552
+
553
+ elif name == "execute_r_with_plot":
554
+ if "code" not in arguments:
555
+ return [types.TextContent(
556
+ type="text",
557
+ text="Error: 'code' parameter is required"
558
+ )]
559
+
560
+ # First, perform the one-time check for ggplot2.
561
+ if not await check_ggplot_installed():
562
+ return [types.TextContent(
563
+ type="text",
564
+ text="Error: The 'ggplot2' package is required for this tool but is not installed. Please install it in RStudio."
565
+ )]
566
+
567
+ # The package is available, so just execute the user's code directly.
568
+ result = await execute_r_code_via_addin(arguments["code"])
569
+
570
+ # Add text output
571
+ if "output" in result and result["output"]:
572
+ result_contents.append(types.TextContent(
573
+ type="text",
574
+ text=result["output"]
575
+ ))
576
+
577
+ # Add error if any
578
+ if not result.get("success", False):
579
+ result_contents.append(types.TextContent(
580
+ type="text",
581
+ text=f"R Error: {result.get('error', 'Unknown error')}"
582
+ ))
583
+
584
+ # Add plot if available
585
+ if "plot" in result:
586
+ result_contents.append(types.ImageContent(
587
+ type="image",
588
+ data=result["plot"]["data"],
589
+ mimeType=result["plot"]["mime_type"]
590
+ ))
591
+
592
+ return result_contents or [types.TextContent(
593
+ type="text",
594
+ text="Code executed but no plot was generated. Make sure your code creates a plot."
595
+ )]
596
+
597
+ elif name == "get_r_info":
598
+ what = arguments.get("what", "all")
599
+
600
+ if what == "packages" or what == "all":
601
+ pkg_code = "installed.packages()[,1]"
602
+ pkg_result = await execute_r_code_via_addin(pkg_code)
603
+ if pkg_result.get("success", False):
604
+ result_contents.append(types.TextContent(
605
+ type="text",
606
+ text=f"Installed R packages:\n{pkg_result.get('output', '')}"
607
+ ))
608
+
609
+ if what == "variables" or what == "all":
610
+ var_code = "ls()"
611
+ var_result = await execute_r_code_via_addin(var_code)
612
+ if var_result.get("success", False):
613
+ result_contents.append(types.TextContent(
614
+ type="text",
615
+ text=f"R variables in global environment:\n{var_result.get('output', '')}"
616
+ ))
617
+
618
+ if what == "version" or what == "all":
619
+ ver_code = "R.version.string"
620
+ ver_result = await execute_r_code_via_addin(ver_code)
621
+ if ver_result.get("success", False):
622
+ result_contents.append(types.TextContent(
623
+ type="text",
624
+ text=f"R version:\n{ver_result.get('output', '')}"
625
+ ))
626
+
627
+ return result_contents or [types.TextContent(
628
+ type="text",
629
+ text=f"Unknown info type: {what}"
630
+ )]
631
+
632
+ elif name == "get_active_document":
633
+ # Get active document content
634
+ result = await execute_r_code_via_addin("""
635
+ if (requireNamespace("rstudioapi", quietly = TRUE) && rstudioapi::isAvailable()) {
636
+ context <- rstudioapi::getActiveDocumentContext()
637
+ list(
638
+ content = paste(context$contents, collapse = "\n"),
639
+ path = context$path,
640
+ line_count = length(context$contents)
641
+ )
642
+ } else {
643
+ list(error = "RStudio API not available")
644
+ }
645
+ """)
646
+
647
+ if not result.get("success", False):
648
+ return [types.TextContent(
649
+ type="text",
650
+ text=f"Error retrieving active document: {result.get('error', 'Unknown error')}"
651
+ )]
652
+
653
+ return [types.TextContent(
654
+ type="text",
655
+ text=result.get("output", "No document content retrieved")
656
+ )]
657
+
658
+ elif name == "create_task_list":
659
+ if "tasks" not in arguments:
660
+ return [types.TextContent(
661
+ type="text",
662
+ text="Error: 'tasks' parameter is required"
663
+ )]
664
+
665
+ # Format the task list as R comments
666
+ task_list_code = """
667
+ # ===== TASK LIST CREATED =====
668
+ # Generated: {}
669
+ #
670
+ """.format(datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
671
+
672
+ for i, task in enumerate(arguments["tasks"], 1):
673
+ task_list_code += f"# Task {task['id']}: {task['description']} [{task['status'].upper()}]\n"
674
+
675
+ task_list_code += "# ===========================\n"
676
+
677
+ # Execute to print in console and log
678
+ result = await execute_r_code_via_addin(f'cat("{escape_r_string(task_list_code)}")')
679
+
680
+ # Convert tasks to R list format with proper escaping
681
+ r_tasks = "list(\n"
682
+ for i, task in enumerate(arguments["tasks"]):
683
+ if i > 0:
684
+ r_tasks += ",\n"
685
+ r_tasks += f""" list(
686
+ id = "{escape_r_string(task['id'])}",
687
+ description = "{escape_r_string(task['description'])}",
688
+ status = "{escape_r_string(task['status'])}"
689
+ )"""
690
+ r_tasks += "\n)"
691
+
692
+ # Store task list in R environment for tracking
693
+ store_code = f"""
694
+ .claude_task_list <- list(
695
+ created = Sys.time(),
696
+ tasks = {r_tasks}
697
+ )
698
+ """
699
+ await execute_r_code_via_addin(store_code)
700
+
701
+ return [types.TextContent(
702
+ type="text",
703
+ text=f"Task list created with {len(arguments['tasks'])} tasks"
704
+ )]
705
+
706
+ elif name == "update_task_status":
707
+ task_id = escape_r_string(arguments.get("task_id", ""))
708
+ status = escape_r_string(arguments.get("status", ""))
709
+ notes = escape_r_string(arguments.get("notes", ""))
710
+
711
+ # Update the task in R environment and print update
712
+ update_code = f"""
713
+ if (exists(".claude_task_list")) {{
714
+ # Update task status
715
+ for (i in seq_along(.claude_task_list$tasks)) {{
716
+ if (.claude_task_list$tasks[[i]]$id == "{task_id}") {{
717
+ .claude_task_list$tasks[[i]]$status <- "{status}"
718
+
719
+ # Print update to console
720
+ update_msg <- paste0(
721
+ "\\n# ===== TASK UPDATE =====\\n",
722
+ "# Time: ", format(Sys.time(), "%H:%M:%S"), "\\n",
723
+ "# Task {task_id}: ", .claude_task_list$tasks[[i]]$description, "\\n",
724
+ "# Status: {status.upper()}\\n"
725
+ )
726
+
727
+ if ("{notes}" != "") {{
728
+ update_msg <- paste0(update_msg, "# Notes: {notes}\\n")
729
+ }}
730
+
731
+ update_msg <- paste0(update_msg, "# ======================\\n")
732
+ cat(update_msg)
733
+
734
+ break
735
+ }}
736
+ }}
737
+
738
+ # Return current task summary
739
+ completed <- sum(sapply(.claude_task_list$tasks, function(t) t$status == "completed"))
740
+ total <- length(.claude_task_list$tasks)
741
+ paste0("Progress: ", completed, "/", total, " tasks completed")
742
+ }} else {{
743
+ "No task list found"
744
+ }}
745
+ """
746
+
747
+ result = await execute_r_code_via_addin(update_code)
748
+
749
+ return [types.TextContent(
750
+ type="text",
751
+ text=result.get("output", "Task updated")
752
+ )]
753
+
754
+
755
+ elif name == "execute_r_async":
756
+ if "code" not in arguments:
757
+ return [types.TextContent(
758
+ type="text",
759
+ text="Error: 'code' parameter is required"
760
+ )]
761
+
762
+ code = arguments["code"]
763
+ job_id = uuid.uuid4().hex[:8]
764
+
765
+ # Send to R — R launches callr::r_bg() and returns immediately
766
+ payload = {
767
+ "code": code,
768
+ "async": True,
769
+ "job_id": job_id,
770
+ }
771
+ if _agent_id:
772
+ payload["agent_id"] = _agent_id
773
+
774
+ result = await post_to_r_addin(payload)
775
+
776
+ if not result.get("success", False):
777
+ return [types.TextContent(
778
+ type="text",
779
+ text=f"Error starting async job: {result.get('error', 'Unknown error')}"
780
+ )]
781
+
782
+ return [types.TextContent(
783
+ type="text",
784
+ text=f"Job {job_id} started in a background R process. The main R session remains available — you can continue running other code with execute_r while this job runs. Use get_async_result(\"{job_id}\") to check status when ready."
785
+ )]
786
+
787
+ elif name == "get_async_result":
788
+ job_id = arguments.get("job_id", "")
789
+
790
+ # Throttle polling — wait before checking
791
+ await asyncio.sleep(10)
792
+
793
+ # Ask R for the job status
794
+ result = await post_to_r_addin({"check_job": job_id})
795
+
796
+ status = result.get("status", "unknown")
797
+
798
+ if status == "not_found":
799
+ return [types.TextContent(
800
+ type="text",
801
+ text=f"No job found with ID '{job_id}'. It may have already completed or the ID is incorrect."
802
+ )]
803
+
804
+ if status == "running":
805
+ elapsed = result.get("elapsed_seconds", "?")
806
+ return [types.TextContent(
807
+ type="text",
808
+ text=f"Job {job_id} is still running ({elapsed}s elapsed). Call get_async_result(\"{job_id}\") again to check."
809
+ )]
810
+
811
+ # Job is complete
812
+ if not result.get("success", False):
813
+ return [types.TextContent(
814
+ type="text",
815
+ text=f"Async job error: {result.get('error', 'Unknown error')}"
816
+ )]
817
+
818
+ result_contents = []
819
+ if "output" in result and result["output"]:
820
+ result_contents.append(types.TextContent(
821
+ type="text",
822
+ text=result["output"]
823
+ ))
824
+
825
+ return result_contents or [types.TextContent(
826
+ type="text",
827
+ text="Async job completed successfully but produced no output."
828
+ )]
829
+
830
+ elif name == "list_sessions":
831
+ sessions = discover_sessions()
832
+ if not sessions:
833
+ return [types.TextContent(
834
+ type="text",
835
+ text="No active R sessions found. Start the ClaudeR addin in RStudio first."
836
+ )]
837
+
838
+ lines = []
839
+ for s in sessions:
840
+ target_marker = " (connected)" if _target_session == s.get("session_name") else ""
841
+ lines.append(
842
+ f" {s.get('session_name', '?')} — port {s.get('port', '?')}, "
843
+ f"pid {s.get('pid', '?')}, started {s.get('started_at', '?')}{target_marker}"
844
+ )
845
+
846
+ header = f"Active R sessions ({len(sessions)}):"
847
+ current = f"Current agent: {_agent_id}"
848
+ target = f"Connected to: {_target_session or 'auto (first available)'}"
849
+ return [types.TextContent(
850
+ type="text",
851
+ text=f"{header}\n" + "\n".join(lines) + f"\n\n{current}\n{target}"
852
+ )]
853
+
854
+ elif name == "connect_session":
855
+ session_name = arguments.get("session_name", "")
856
+ if not session_name:
857
+ return [types.TextContent(
858
+ type="text",
859
+ text="Error: 'session_name' is required"
860
+ )]
861
+
862
+ sessions = discover_sessions()
863
+ found = any(s.get("session_name") == session_name for s in sessions)
864
+
865
+ if not found:
866
+ available = [s.get("session_name", "?") for s in sessions]
867
+ return [types.TextContent(
868
+ type="text",
869
+ text=f"Session '{session_name}' not found. Available: {available or 'none'}"
870
+ )]
871
+
872
+ _target_session = session_name
873
+ return [types.TextContent(
874
+ type="text",
875
+ text=f"Connected to session '{session_name}'. All subsequent tool calls will be routed there."
876
+ )]
877
+
878
+ elif name == "get_session_history":
879
+ agent_filter = arguments.get("agent_filter", "all")
880
+ last_n = int(arguments.get("last_n", 20))
881
+
882
+ # Translate "self" to this agent's actual ID
883
+ if agent_filter == "self":
884
+ filter_value = escape_r_string(_agent_id or "unknown")
885
+ elif agent_filter == "all":
886
+ filter_value = "all"
887
+ else:
888
+ filter_value = escape_r_string(agent_filter)
889
+
890
+ r_code = f'ClaudeR:::query_agent_history("{filter_value}", "{escape_r_string(_agent_id or "unknown")}", {last_n})'
891
+ result = await execute_r_code_via_addin(r_code)
892
+
893
+ if not result.get("success", False):
894
+ return [types.TextContent(
895
+ type="text",
896
+ text=f"Error querying history: {result.get('error', 'Unknown error')}"
897
+ )]
898
+
899
+ return [types.TextContent(
900
+ type="text",
901
+ text=result.get("output", "No history available")
902
+ )]
903
+
904
+ elif name == "read_file":
905
+ if "file_path" not in arguments:
906
+ return [types.TextContent(type="text", text="Error: 'file_path' parameter is required")]
907
+
908
+ file_path = escape_r_string(arguments["file_path"])
909
+ read_code = f'''
910
+ tryCatch({{
911
+ fpath <- path.expand("{file_path}")
912
+ if (!file.exists(fpath)) {{
913
+ list(success = FALSE, error = paste0("File not found: ", fpath))
914
+ }} else {{
915
+ lines <- readLines(fpath, warn = FALSE)
916
+ numbered <- paste0(seq_along(lines), ": ", lines)
917
+ list(success = TRUE, output = paste(numbered, collapse = "\\n"))
918
+ }}
919
+ }}, error = function(e) {{
920
+ list(success = FALSE, error = e$message)
921
+ }})
922
+ '''
923
+ result = await execute_r_code_via_addin(read_code)
924
+
925
+ if not result.get("success", False):
926
+ error_msg = result.get("error", "Unknown error")
927
+ result_contents.append(types.TextContent(type="text", text=f"Error reading file: {error_msg}"))
928
+ return result_contents
929
+
930
+ result_contents.append(types.TextContent(
931
+ type="text",
932
+ text=result.get("output", "File is empty")
933
+ ))
934
+ return result_contents
935
+
936
+ elif name == "modify_code_section":
937
+ if not all(k in arguments for k in ["search_pattern", "replacement"]):
938
+ return [types.TextContent(
939
+ type="text",
940
+ text="Error: Both 'search_pattern' and 'replacement' parameters are required"
941
+ )]
942
+
943
+ # Escape special characters for R string
944
+ search_pattern = arguments["search_pattern"].replace("\\", "\\\\").replace("\"", "\\\"").replace("'", "\\'")
945
+ replacement = arguments["replacement"].replace("\\", "\\\\").replace("\"", "\\\"").replace("'", "\\'")
946
+
947
+ # Get line constraints if provided
948
+ line_start = arguments.get("line_start", "NULL")
949
+ line_end = arguments.get("line_end", "NULL")
950
+
951
+ modify_code = f"""
952
+ if (requireNamespace("rstudioapi", quietly = TRUE) && rstudioapi::isAvailable()) {{
953
+ context <- rstudioapi::getActiveDocumentContext()
954
+ content <- context$contents
955
+
956
+ # Convert to a single string for pattern matching
957
+ full_text <- paste(content, collapse = "\\n")
958
+
959
+ # Apply line constraints if provided
960
+ line_start <- {line_start}
961
+ line_end <- {line_end}
962
+
963
+ if (!is.null(line_start) && !is.null(line_end)) {{
964
+ # Work with a subset of lines
965
+ if (line_start > 0 && line_end <= length(content) && line_start <= line_end) {{
966
+ subset_lines <- content[line_start:line_end]
967
+ subset_text <- paste(subset_lines, collapse = "\\n")
968
+
969
+ # Apply replacement in the subset
970
+ search_pattern <- "{search_pattern}"
971
+ modified_subset <- gsub(search_pattern, "{replacement}", subset_text, perl = TRUE)
972
+
973
+ # Split back into lines
974
+ modified_lines <- strsplit(modified_subset, "\\n")[[1]]
975
+
976
+ # Update the content
977
+ if (length(modified_lines) == length(subset_lines)) {{
978
+ content[line_start:line_end] <- modified_lines
979
+ rstudioapi::setDocumentContents(paste(content, collapse = "\\n"), id = context$id)
980
+ list(
981
+ success = TRUE,
982
+ message = paste0("Modified code between lines ", line_start, " and ", line_end)
983
+ )
984
+ }} else {{
985
+ list(
986
+ success = FALSE,
987
+ error = "Replacement resulted in different number of lines"
988
+ )
989
+ }}
990
+ }} else {{
991
+ list(
992
+ success = FALSE,
993
+ error = paste0("Invalid line range: ", line_start, "-", line_end,
994
+ ". Document has ", length(content), " lines.")
995
+ )
996
+ }}
997
+ }} else {{
998
+ # Apply replacement to entire document
999
+ modified_text <- gsub("{search_pattern}", "{replacement}", full_text, perl = TRUE)
1000
+
1001
+ if (modified_text != full_text) {{
1002
+ rstudioapi::setDocumentContents(modified_text, id = context$id)
1003
+ list(
1004
+ success = TRUE,
1005
+ message = "Modified code in the document"
1006
+ )
1007
+ }} else {{
1008
+ list(
1009
+ success = FALSE,
1010
+ error = "Pattern not found in document"
1011
+ )
1012
+ }}
1013
+ }}
1014
+ }} else {{
1015
+ list(
1016
+ success = FALSE,
1017
+ error = "RStudio API not available"
1018
+ )
1019
+ }}
1020
+ """
1021
+
1022
+ result = await execute_r_code_via_addin(modify_code)
1023
+
1024
+ if not result.get("success", False):
1025
+ return [types.TextContent(
1026
+ type="text",
1027
+ text=f"Error modifying code: {result.get('error', 'Unknown error')}"
1028
+ )]
1029
+
1030
+ return [types.TextContent(
1031
+ type="text",
1032
+ text=result.get("output", "No result returned from code modification")
1033
+ )]
1034
+
1035
+ return [types.TextContent(
1036
+ type="text",
1037
+ text=f"Unknown tool: {name}"
1038
+ )]
1039
+
1040
+ # Run the server
1041
+ async def serve():
1042
+ global _agent_id
1043
+
1044
+ args = parse_args()
1045
+ _agent_id = args.agent_id or f"agent-{uuid.uuid4().hex[:8]}"
1046
+
1047
+ # Discover sessions
1048
+ sessions = discover_sessions()
1049
+ session_info = f", {len(sessions)} session(s) found" if sessions else ", no sessions yet"
1050
+
1051
+ print(f"Starting R Studio MCP server (agent={_agent_id}{session_info})...", file=sys.stderr)
1052
+ async with stdio_server() as (read_stream, write_stream):
1053
+ await server.run(
1054
+ read_stream,
1055
+ write_stream,
1056
+ server.create_initialization_options()
1057
+ )
1058
+
1059
+ if __name__ == "__main__":
1060
+ asyncio.run(serve())