Skip to content

Commit ff9a812

Browse files
authored
merge pr #13: refer to R sessions of interest as "hosts"
2 parents d183542 + 12ba63f commit ff9a812

File tree

12 files changed

+392
-390
lines changed

12 files changed

+392
-390
lines changed

DESCRIPTION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Suggests:
2121
Config/testthat/edition: 3
2222
Encoding: UTF-8
2323
Roxygen: list(markdown = TRUE)
24-
RoxygenNote: 7.3.2.9000
24+
RoxygenNote: 7.3.2
2525
Imports:
2626
btw (>= 0.0.1.9000),
2727
cli,

NAMESPACE

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Generated by roxygen2: do not edit by hand
22

3-
export(mcp_proxy)
4-
export(mcp_serve)
3+
export(mcp_host)
4+
export(mcp_server)
55
import(rlang)

R/host.R

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
#' Model context protocol for your R session
2+
#'
3+
#' @description
4+
#' Together, these functions implement a model context protocol server for your
5+
#' R session.
6+
#'
7+
#' @section Configuration:
8+
#'
9+
#' [mcp_server()] should be configured with the MCP clients via the `Rscript`
10+
#' command. For example, to use with Claude Desktop, paste the following in your
11+
#' Claude Desktop configuration (on macOS, at
12+
#' `file.edit("~/Library/Application Support/Claude/claude_desktop_config.json")`):
13+
#'
14+
#' ```json
15+
#' {
16+
#' "mcpServers": {
17+
#' "r-acquaint": {
18+
#' "command": "Rscript",
19+
#' "args": ["-e", "acquaint::mcp_server()"]
20+
#' }
21+
#' }
22+
#' }
23+
#' ```
24+
#'
25+
#' Or, to use with Claude Code, you might type in a terminal:
26+
#'
27+
#' ```bash
28+
#' claude mcp add -s "user" r-acquaint Rscript -e "acquaint::mcp_server()"
29+
#' ```
30+
#'
31+
#' **mcp_server() is not intended for interactive use.**
32+
#'
33+
#' The server interfaces with the MCP client on behalf of the host in
34+
#' your R session. **Use [mcp_host()] to start the host in your R session.**
35+
#' Place a call to `acquaint::mcp_host()` in your `.Rprofile`, perhaps with
36+
#' `usethis::edit_r_profile()`, to start a host for every interactive R session
37+
#' you start.
38+
#'
39+
#' @examples
40+
#' if (interactive()) {
41+
#' mcp_host()
42+
#' }
43+
#'
44+
#' @name mcp
45+
#' @export
46+
mcp_host <- function() {
47+
# HACK: If a host is already running in one session via `.Rprofile`,
48+
# `mcp_host()` will be called again when the client runs the command
49+
# Rscript -e "acquaint::mcp_server()" and the existing host will be wiped.
50+
# Returning early in this case allows for the desired R session host to be
51+
# running already before the client initiates the server.
52+
if (!interactive()) {
53+
return(invisible())
54+
}
55+
56+
the$host_socket <- nanonext::socket("poly")
57+
i <- 1L
58+
suppressWarnings(
59+
while (i < 1024L) {
60+
# prevent indefinite loop
61+
nanonext::listen(
62+
the$host_socket,
63+
url = sprintf("%s%d", acquaint_socket, i)
64+
) ||
65+
break
66+
i <- i + 1L
67+
}
68+
)
69+
70+
schedule_handle_message_from_server()
71+
}
72+
73+
handle_message_from_server <- function(msg) {
74+
pipe <- nanonext::pipe_id(the$raio)
75+
schedule_handle_message_from_server()
76+
77+
# cat("RECV :", msg, "\n", sep = "", file = stderr())
78+
if (!nzchar(msg)) {
79+
return(nanonext::send_aio(the$host_socket, commandArgs(), pipe = pipe))
80+
}
81+
data <- jsonlite::parse_json(msg)
82+
83+
if (data$method == "tools/call") {
84+
name <- data$params$name
85+
fn <- getNamespace("btw")[[name]]
86+
args <- data$params$arguments
87+
88+
# HACK for btw_tool_env_describe_environment. In the JSON, it will have
89+
# `"items": []`, and that translates to an empty list, but we want NULL.
90+
if (name == "btw_tool_env_describe_environment") {
91+
if (identical(args$items, list())) {
92+
args$items <- NULL
93+
}
94+
}
95+
96+
tool_call_result <- do.call(fn, args)
97+
# cat(paste(capture.output(str(body)), collapse="\n"), file=stderr())
98+
99+
body <- jsonrpc_response(
100+
data$id,
101+
list(
102+
content = list(
103+
list(
104+
type = "text",
105+
text = paste(tool_call_result, collapse = "\n")
106+
)
107+
),
108+
isError = FALSE
109+
)
110+
)
111+
} else {
112+
body <- jsonrpc_response(
113+
data$id,
114+
error = list(code = -32601, message = "Method not found")
115+
)
116+
}
117+
# cat("SEND:", to_json(body), "\n", sep = "", file = stderr())
118+
119+
nanonext::send_aio(
120+
the$host_socket,
121+
to_json(body),
122+
mode = "raw",
123+
pipe = pipe
124+
)
125+
}
126+
127+
schedule_handle_message_from_server <- function() {
128+
the$raio <- nanonext::recv_aio(the$host_socket, mode = "string")
129+
promises::as.promise(the$raio)$then(handle_message_from_server)$catch(
130+
function(
131+
e
132+
) {
133+
print(e)
134+
}
135+
)
136+
}
137+
138+
# Create a jsonrpc-structured response object.
139+
140+
# Given a vector or list, drop all the NULL items in it
141+
drop_nulls <- function(x) {
142+
x[!vapply(x, is.null, FUN.VALUE = logical(1))]
143+
}

0 commit comments

Comments
 (0)