Skip to content

Commit 60154a9

Browse files
authored
implement http transport in client and server (#79)
1 parent 5cd5a84 commit 60154a9

File tree

10 files changed

+494
-59
lines changed

10 files changed

+494
-59
lines changed

CLAUDE.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,63 @@ The server uses a condition variable (`cv`) to coordinate multiple async operati
4646
- Server dials to `inproc://mcptools-session-1` by default
4747
- `inproc://` transport is fast for same-machine communication
4848
- Connections are cleaned up with `nanonext::reap()` on exit
49+
50+
## HTTP Transport Implementation
51+
52+
### Current Status
53+
54+
The HTTP transport implementation follows the "MUSTs only" principle from the MCP specification:
55+
56+
**Implemented (MUSTs):**
57+
- HTTP POST endpoint for JSON-RPC messages
58+
- `MCP-Protocol-Version` header validation
59+
- Origin validation for DNS rebinding protection
60+
- Client-side session ID tracking (if server provides `Mcp-Session-Id` header)
61+
- GET endpoint (returns 405 - SSE streaming not implemented)
62+
63+
**Not Implemented (MAYs/SHOULDs):**
64+
- Server-side session management (MAY in spec)
65+
- SSE streaming for GET requests (optional)
66+
- OAuth 2.1 authentication (OPTIONAL in spec)
67+
- HTTPS/TLS support
68+
69+
### HTTP vs HTTPS
70+
71+
**Protocol Requirements:**
72+
- The MCP protocol does NOT require HTTPS for authless HTTP servers
73+
- HTTPS is only REQUIRED for OAuth/authorization endpoints (which we don't implement)
74+
75+
**Client Compatibility:**
76+
- **Claude Code**: Works with `http://` servers ✓
77+
- **Claude Desktop**: Requires `https://` servers (product policy, not protocol requirement) ✗
78+
79+
Since HTTPS requires SSL certificates (self-signed for local dev, proper certs for production),
80+
we defer HTTPS support until OAuth 2.1 is implemented, when it becomes a MUST.
81+
82+
### Testing with the Inspector
83+
84+
The MCP inspector helps test MCP servers:
85+
86+
```bash
87+
Rscript -e "mcptools::mcp_server(type = 'http', port = 9000)"
88+
```
89+
90+
Then:
91+
92+
```bash
93+
npx @modelcontextprotocol/inspector --transport http --server-url http://127.0.0.1:9000
94+
```
95+
96+
### Using with Claude Code
97+
98+
Add the HTTP server to Claude Code:
99+
100+
```bash
101+
claude mcp add --transport http r-mcptools-http http://127.0.0.1:9000
102+
```
103+
104+
Remove it:
105+
106+
```bash
107+
claude mcp remove r-mcptools-http
108+
```

DESCRIPTION

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ URL: https://github.com/posit-dev/mcptools,
2424
BugReports: https://github.com/posit-dev/mcptools/issues
2525
Depends:
2626
R (>= 4.1.0)
27-
Imports:
27+
Imports:
2828
cli,
2929
ellmer (>= 0.3.0),
30+
httpuv,
31+
httr2,
3032
jsonlite,
3133
nanonext (>= 1.6.0),
3234
processx,

NEWS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# mcptools (development version)
22

3+
* `mcp_server()` now supports HTTP transport in addition to stdio. Use `type = "http"` to start an HTTP server, with optional `host` and `port` arguments. For now, the implementation is authless.
4+
5+
* `mcp_tools()` now supports connecting to HTTP-based MCP servers. Configure servers with a `url` field in the config file instead of `command`/`args`.
6+
37
* JSON-RPC responses now retain an explicit `id = NULL` value, ensuring parse-error replies conform to the MCP specification.
48

59
* `mcp_server()` now formats tool results in the same way as ellmer (#78 by @gadenbuie).

R/client.R

Lines changed: 142 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,12 @@ the$mcp_servers <- list()
3838
#' file with `file.edit(file.path("~", ".config", "mcptools", "config.json"))`.
3939
#'
4040
#' The mcptools config file should be valid .json with an entry `mcpServers`.
41-
#' That entry should contain named elements, each with at least a `command`
42-
#' and `args` entry.
41+
#' That entry should contain named elements, each configured for either
42+
#' **stdio** or **HTTP** transport.
4343
#'
44-
#' For example, to configure `mcp_tools()` with GitHub's official MCP Server
45-
#' <https://github.com/github/github-mcp-server>, you could write the following
46-
#' in that file:
44+
#' ## Local servers (via stdio)
45+
#'
46+
#' For stdio-based servers, provide `command` and `args` entries:
4747
#'
4848
#' ```json
4949
#' {
@@ -66,6 +66,23 @@ the$mcp_servers <- list()
6666
#' }
6767
#' ```
6868
#'
69+
#' ## Remote servers (via http)
70+
#'
71+
#' For HTTP-based servers, provide a `url` entry instead of `command`/`args`:
72+
#'
73+
#' ```json
74+
#' {
75+
#' "mcpServers": {
76+
#' "local-http": {
77+
#' "url": "https://localhost:8080"
78+
#' },
79+
#' "remote-http": {
80+
#' "url": "https://mcp.example.com/mcp"
81+
#' }
82+
#' }
83+
#' }
84+
#' ```
85+
#'
6986
#' @returns
7087
#' * `mcp_tools()` returns a list of ellmer tools that can be passed directly
7188
#' to the `$set_tools()` method of an [ellmer::Chat] object. If the file at
@@ -100,33 +117,91 @@ mcp_tools <- function(config = NULL) {
100117
for (i in seq_along(config)) {
101118
config_i <- config[[i]]
102119
name_i <- names(config)[i]
103-
config_i_env <- if ("env" %in% names(config_i)) {
104-
unlist(config_i$env)
120+
121+
if ("url" %in% names(config_i)) {
122+
add_mcp_server_http(config = config_i, name = name_i)
105123
} else {
106-
NULL
124+
add_mcp_server_stdio(config = config_i, name = name_i)
107125
}
126+
}
108127

109-
process <- processx::process$new(
110-
# seems like the R process has a different PATH than process_exec
111-
command = Sys.which(config_i$command),
112-
args = config_i$args,
113-
env = config_i_env,
114-
stdin = "|",
115-
stdout = "|",
116-
stderr = "|"
117-
)
128+
servers_as_ellmer_tools()
129+
}
118130

119-
the$server_processes <- c(
120-
the$server_processes,
121-
list2(
122-
!!paste0(c(config_i$command, config_i$args), collapse = " ") := process
123-
)
131+
add_mcp_server_stdio <- function(config, name) {
132+
config_env <- if ("env" %in% names(config)) {
133+
unlist(config$env)
134+
} else {
135+
NULL
136+
}
137+
138+
process <- processx::process$new(
139+
command = Sys.which(config$command),
140+
args = config$args,
141+
env = config_env,
142+
stdin = "|",
143+
stdout = "|",
144+
stderr = "|"
145+
)
146+
147+
the$server_processes <- c(
148+
the$server_processes,
149+
list2(
150+
!!paste0(c(config$command, config$args), collapse = " ") := process
124151
)
152+
)
125153

126-
add_mcp_server(process = process, name = name_i)
127-
}
154+
response_initialize <- send_and_receive_stdio(
155+
process,
156+
mcp_request_initialize()
157+
)
158+
send_and_receive_stdio(process, mcp_request_initialized())
159+
response_tools_list <- send_and_receive_stdio(
160+
process,
161+
mcp_request_tools_list()
162+
)
128163

129-
servers_as_ellmer_tools()
164+
the$mcp_servers[[name]] <- list(
165+
name = name,
166+
type = "stdio",
167+
process = process,
168+
tools = response_tools_list$result,
169+
id = 3
170+
)
171+
172+
the$mcp_servers[[name]]
173+
}
174+
175+
add_mcp_server_http <- function(config, name) {
176+
response_initialize <- send_and_receive_http(
177+
url = config$url,
178+
request = mcp_request_initialize()
179+
)
180+
181+
session_id <- response_initialize$session_id
182+
183+
send_and_receive_http(
184+
url = config$url,
185+
request = mcp_request_initialized(),
186+
session_id = session_id
187+
)
188+
189+
response_tools_list <- send_and_receive_http(
190+
url = config$url,
191+
request = mcp_request_tools_list(),
192+
session_id = session_id
193+
)
194+
195+
the$mcp_servers[[name]] <- list(
196+
name = name,
197+
type = "http",
198+
url = config$url,
199+
session_id = session_id,
200+
tools = response_tools_list$result,
201+
id = 3
202+
)
203+
204+
the$mcp_servers[[name]]
130205
}
131206

132207
mcp_client_config <- function() {
@@ -193,19 +268,34 @@ error_no_mcp_config <- function(call) {
193268
)
194269
}
195270

196-
add_mcp_server <- function(process, name) {
197-
response_initialize <- send_and_receive(process, mcp_request_initialize())
198-
send_and_receive(process, mcp_request_initialized())
199-
response_tools_list <- send_and_receive(process, mcp_request_tools_list())
271+
send_and_receive_http <- function(url, request, session_id = NULL) {
272+
req <- httr2::request(url) |>
273+
httr2::req_method("POST") |>
274+
httr2::req_headers(
275+
"Content-Type" = "application/json",
276+
"Accept" = "application/json",
277+
"MCP-Protocol-Version" = "2025-06-18"
278+
) |>
279+
httr2::req_body_json(request)
280+
281+
if (!is.null(session_id)) {
282+
req <- httr2::req_headers(req, "Mcp-Session-Id" = session_id)
283+
}
284+
285+
resp <- httr2::req_perform(req)
200286

201-
the$mcp_servers[[name]] <- list(
202-
name = name,
203-
process = process,
204-
tools = response_tools_list$result,
205-
id = 3
206-
)
287+
if (httr2::resp_status(resp) == 202L || httr2::resp_body_string(resp) == "") {
288+
return(NULL)
289+
}
207290

208-
the$mcp_servers[[name]]
291+
body <- httr2::resp_body_json(resp)
292+
293+
session_id_header <- httr2::resp_header(resp, "Mcp-Session-Id")
294+
if (!is.null(session_id_header)) {
295+
body$session_id <- session_id_header
296+
}
297+
298+
body
209299
}
210300

211301
servers_as_ellmer_tools <- function() {
@@ -347,13 +437,21 @@ tool_ref <- function(server, tool, arguments) {
347437
}
348438

349439
call_tool <- function(..., server, tool) {
350-
server_process <- the$mcp_servers[[server]]$process
351-
send_and_receive(
352-
server_process,
353-
mcp_request_tool_call(
354-
id = jsonrpc_id(server),
355-
tool = tool,
356-
arguments = list(...)
440+
server_config <- the$mcp_servers[[server]]
441+
442+
request <- mcp_request_tool_call(
443+
id = jsonrpc_id(server),
444+
tool = tool,
445+
arguments = list(...)
446+
)
447+
448+
switch(
449+
server_config$type,
450+
stdio = send_and_receive_stdio(server_config$process, request),
451+
http = send_and_receive_http(
452+
url = server_config$url,
453+
request = request,
454+
session_id = server_config$session_id
357455
)
358456
)
359457
}
@@ -372,13 +470,11 @@ log_cat_client <- function(x, append = TRUE) {
372470
cat(x, "\n\n", sep = "", append = append, file = log_file)
373471
}
374472

375-
send_and_receive <- function(process, message) {
376-
# send the message
473+
send_and_receive_stdio <- function(process, message) {
377474
json_msg <- jsonlite::toJSON(message, auto_unbox = TRUE)
378475
log_cat_client(c("FROM CLIENT: ", json_msg))
379476
process$write_input(paste0(json_msg, "\n"))
380477

381-
# poll for response
382478
output <- NULL
383479
attempts <- 0
384480
max_attempts <- 20

0 commit comments

Comments
 (0)