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,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,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())
|