|
| 1 | +# Programmatic Access from R & Python |
| 2 | + |
| 3 | +The MCP server speaks [streamable HTTP](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http), so you can call its tools from any language — no LLM client required. You can also wire the tools into an LLM agent so the model writes and runs queries on its own. |
| 4 | + |
| 5 | +Full runnable scripts are in the [`examples/`](https://github.com/boettiger-lab/mcp-data-server/tree/main/examples) folder. |
| 6 | + |
| 7 | +## Direct queries (no LLM) |
| 8 | + |
| 9 | +Call the MCP `query` tool directly to run SQL against S3 Parquet files. |
| 10 | + |
| 11 | +### Python |
| 12 | + |
| 13 | +The official `mcp` SDK speaks streamable HTTP natively. |
| 14 | + |
| 15 | +```bash |
| 16 | +pip install mcp |
| 17 | +``` |
| 18 | + |
| 19 | +```python |
| 20 | +import asyncio |
| 21 | +from mcp import ClientSession |
| 22 | +from mcp.client.streamable_http import streamablehttp_client |
| 23 | + |
| 24 | +MCP_URL = "https://duckdb-mcp.nrp-nautilus.io/mcp" |
| 25 | + |
| 26 | +SQL = """ |
| 27 | +SELECT country, name_en, subtype |
| 28 | +FROM read_parquet('s3://public-overturemaps/2026-02-18.0/countries.parquet') |
| 29 | +WHERE subtype = 'country' AND is_land |
| 30 | +ORDER BY name_en |
| 31 | +LIMIT 10 |
| 32 | +""" |
| 33 | + |
| 34 | +async def main(): |
| 35 | + async with streamablehttp_client(MCP_URL) as (read, write, _): |
| 36 | + async with ClientSession(read, write) as session: |
| 37 | + await session.initialize() |
| 38 | + |
| 39 | + tools = await session.list_tools() |
| 40 | + print("Available tools:", [t.name for t in tools.tools]) |
| 41 | + |
| 42 | + result = await session.call_tool("query", {"sql_query": SQL}) |
| 43 | + for block in result.content: |
| 44 | + print(block.text) |
| 45 | + |
| 46 | +asyncio.run(main()) |
| 47 | +``` |
| 48 | + |
| 49 | +### R |
| 50 | + |
| 51 | +There is no R MCP client library yet. The server runs in stateless mode, so you can hit the JSON-RPC endpoint directly with `httr2`. Responses arrive as server-sent events (SSE). |
| 52 | + |
| 53 | +```r |
| 54 | +library(httr2) |
| 55 | +library(jsonlite) |
| 56 | + |
| 57 | +mcp_url <- "https://duckdb-mcp.nrp-nautilus.io/mcp" |
| 58 | + |
| 59 | +sql <- " |
| 60 | +SELECT country, name_en, subtype |
| 61 | +FROM read_parquet('s3://public-overturemaps/2026-02-18.0/countries.parquet') |
| 62 | +WHERE subtype = 'country' AND is_land |
| 63 | +ORDER BY name_en |
| 64 | +LIMIT 10 |
| 65 | +" |
| 66 | + |
| 67 | +parse_sse <- function(body) { |
| 68 | + lines <- strsplit(body, "\n", fixed = TRUE)[[1]] |
| 69 | + data_lines <- sub("^data: ", "", lines[grepl("^data: ", lines)]) |
| 70 | + lapply(data_lines, fromJSON, simplifyVector = FALSE) |
| 71 | +} |
| 72 | + |
| 73 | +mcp_call <- function(method, params, id = 1L) { |
| 74 | + resp <- request(mcp_url) |> |
| 75 | + req_headers( |
| 76 | + Accept = "application/json, text/event-stream", |
| 77 | + `Content-Type` = "application/json" |
| 78 | + ) |> |
| 79 | + req_body_json(list( |
| 80 | + jsonrpc = "2.0", |
| 81 | + id = id, |
| 82 | + method = method, |
| 83 | + params = params |
| 84 | + )) |> |
| 85 | + req_perform() |
| 86 | + |
| 87 | + body <- resp_body_string(resp) |
| 88 | + ctype <- resp_content_type(resp) |
| 89 | + if (grepl("event-stream", ctype, fixed = TRUE)) { |
| 90 | + msgs <- parse_sse(body) |
| 91 | + msgs[[length(msgs)]] |
| 92 | + } else { |
| 93 | + fromJSON(body, simplifyVector = FALSE) |
| 94 | + } |
| 95 | +} |
| 96 | + |
| 97 | +resp <- mcp_call("tools/call", list( |
| 98 | + name = "query", |
| 99 | + arguments = list(sql_query = sql) |
| 100 | +)) |
| 101 | + |
| 102 | +for (block in resp$result$content) { |
| 103 | + cat(block$text, "\n") |
| 104 | +} |
| 105 | +``` |
| 106 | + |
| 107 | +## LLM tool use |
| 108 | + |
| 109 | +Let the model discover datasets, write SQL, and interpret results autonomously. The MCP tools (`browse_stac_catalog`, `get_stac_details`, `query`) are registered as callable tools so the model decides when and how to use them. |
| 110 | + |
| 111 | +Both examples below use `ChatOpenAI` / `chat_openai()` and work with any OpenAI-compatible endpoint. Set `OPENAI_API_KEY` and optionally `OPENAI_BASE_URL` in your environment. |
| 112 | + |
| 113 | +### Python — LangChain + LangGraph |
| 114 | + |
| 115 | +```bash |
| 116 | +pip install langchain-mcp-adapters langchain-openai langgraph |
| 117 | +``` |
| 118 | + |
| 119 | +```python |
| 120 | +import asyncio |
| 121 | +import os |
| 122 | +from langchain_openai import ChatOpenAI |
| 123 | +from langchain_mcp_adapters.client import MultiServerMCPClient |
| 124 | +from langgraph.prebuilt import create_react_agent |
| 125 | + |
| 126 | +MCP_URL = "https://duckdb-mcp.nrp-nautilus.io/mcp" |
| 127 | + |
| 128 | +async def main(): |
| 129 | + client = MultiServerMCPClient({ |
| 130 | + "duckdb-geo": { |
| 131 | + "url": MCP_URL, |
| 132 | + "transport": "streamable_http", |
| 133 | + } |
| 134 | + }) |
| 135 | + tools = await client.get_tools() |
| 136 | + |
| 137 | + model = ChatOpenAI( |
| 138 | + model=os.environ.get("MODEL", "gpt-4o"), |
| 139 | + max_tokens=4096, |
| 140 | + ) |
| 141 | + agent = create_react_agent(model, tools) |
| 142 | + |
| 143 | + result = await agent.ainvoke( |
| 144 | + {"messages": [{"role": "user", |
| 145 | + "content": "What fraction of Australia is protected area?"}]} |
| 146 | + ) |
| 147 | + print(result["messages"][-1].content) |
| 148 | + |
| 149 | +asyncio.run(main()) |
| 150 | +``` |
| 151 | + |
| 152 | +### R — ellmer + mcptools |
| 153 | + |
| 154 | +[`mcptools`](https://github.com/tidyverse/mcptools) is an MCP client for R that plugs MCP tools into `ellmer` chats. It speaks stdio, so we bridge to the remote HTTP server with [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) (requires Node.js on PATH). |
| 155 | + |
| 156 | +```r |
| 157 | +library(mcptools) |
| 158 | +library(ellmer) |
| 159 | +library(jsonlite) |
| 160 | + |
| 161 | +mcp_url <- "https://duckdb-mcp.nrp-nautilus.io/mcp" |
| 162 | + |
| 163 | +# Build a config pointing mcptools at the remote server via mcp-remote. |
| 164 | +config_file <- tempfile(fileext = ".json") |
| 165 | +write_json( |
| 166 | + list(mcpServers = list( |
| 167 | + `duckdb-geo` = list( |
| 168 | + command = "npx", |
| 169 | + args = list("-y", "mcp-remote", mcp_url) |
| 170 | + ) |
| 171 | + )), |
| 172 | + config_file, |
| 173 | + auto_unbox = TRUE, pretty = TRUE |
| 174 | +) |
| 175 | + |
| 176 | +# Fetch MCP tools as ellmer-compatible tool definitions. |
| 177 | +tools <- mcp_tools(config = config_file) |
| 178 | + |
| 179 | +# Create a chat session and register the tools. |
| 180 | +chat <- chat_openai( |
| 181 | + model = Sys.getenv("MODEL", "gpt-4o"), |
| 182 | + echo = "output" |
| 183 | +) |
| 184 | +chat$set_tools(tools) |
| 185 | + |
| 186 | +chat$chat("What fraction of Australia is protected area?") |
| 187 | +``` |
| 188 | + |
| 189 | +::: tip |
| 190 | +You can use the same pattern to talk to a local dev server at `http://localhost:8000/mcp` — just change the URL. |
| 191 | +::: |
0 commit comments