Skip to content

Commit 964c281

Browse files
authored
Add R & Python examples and programmatic access docs (#53)
* h3-guide: add resolution direction and pre-computed parent column join pattern Fixes #44 Two gaps caused a model to spiral for 26 turns on a cross-resolution join: 1. No explicit statement that higher H3 resolution numbers are finer/children 2. No guidance to use pre-computed parent columns (e.g. h6 on an h8 dataset) before falling back to h3_cell_to_parent() * Add R & Python examples and programmatic access docs Four example scripts showing direct MCP queries (no LLM) and LLM tool-use via LangChain and ellmer, plus a new docs page covering both patterns.
1 parent f31d233 commit 964c281

7 files changed

Lines changed: 427 additions & 0 deletions

File tree

docs/.vitepress/config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export default {
1717
{ text: 'Quick Start', link: '/guide/quickstart' },
1818
{ text: 'Available Datasets', link: '/guide/datasets' },
1919
{ text: 'Private Data Access', link: '/guide/private-data' },
20+
{ text: 'Programmatic Access (R & Python)', link: '/guide/programmatic-access' },
2021
],
2122
},
2223
{

docs/guide/programmatic-access.md

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
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+
:::

examples/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Examples
2+
3+
Four small scripts showing two ways to talk to the duckdb-geo MCP server:
4+
5+
| File | What it does |
6+
|---|---|
7+
| [query.py](query.py) | Direct MCP `query` tool call from Python (no LLM) |
8+
| [query.R](query.R) | Direct MCP `query` tool call from R via JSON-RPC over HTTP |
9+
| [agent_langchain.py](agent_langchain.py) | LangGraph ReAct agent that calls MCP tools via tool use |
10+
| [agent_ellmer.R](agent_ellmer.R) | ellmer chat that calls MCP tools via tool use |
11+
12+
All scripts target the public endpoint `https://duckdb-mcp.nrp-nautilus.io/mcp`.
13+
The agent examples use `langchain-openai` / `ellmer::chat_openai()` so they work with any OpenAI-compatible endpoint — set `OPENAI_API_KEY` (and optionally `OPENAI_BASE_URL` and `MODEL`) in your environment. The R agent example also requires Node.js (for `npx mcp-remote`).

examples/agent_ellmer.R

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# ellmer + mcptools example: let an LLM decide when to call the duckdb-geo
2+
# MCP tools.
3+
#
4+
# mcptools is an MCP *client* for R that plugs MCP tools into ellmer chats.
5+
# It only speaks stdio, so we bridge to the remote HTTP server through
6+
# `mcp-remote` (an npx-based stdio <-> HTTP proxy).
7+
#
8+
# Requirements:
9+
# - Node.js / npx on PATH (npx fetches mcp-remote on first use)
10+
# - install.packages(c("ellmer", "mcptools"))
11+
#
12+
# Set:
13+
# export OPENAI_API_KEY=... # or any OpenAI-compatible key
14+
# export OPENAI_BASE_URL=... # optional, defaults to OpenAI
15+
#
16+
# Run:
17+
# Rscript agent_ellmer.R
18+
19+
library(mcptools)
20+
library(ellmer)
21+
library(jsonlite)
22+
23+
mcp_url <- "https://duckdb-mcp.nrp-nautilus.io/mcp"
24+
25+
# Build a Claude-Desktop-style config for mcptools.
26+
# mcp-remote bridges stdio <-> streamable-HTTP so mcptools can connect
27+
# to the remote MCP server.
28+
config_file <- tempfile(fileext = ".json")
29+
write_json(
30+
list(
31+
mcpServers = list(
32+
`duckdb-geo` = list(
33+
command = "npx",
34+
args = list("-y", "mcp-remote", mcp_url)
35+
)
36+
)
37+
),
38+
config_file,
39+
auto_unbox = TRUE,
40+
pretty = TRUE
41+
)
42+
43+
# Fetch the remote server's tools as ellmer-compatible tool definitions.
44+
tools <- mcp_tools(config = config_file)
45+
cat("Available tools:", vapply(tools, \(t) t@name, character(1)), "\n")
46+
47+
# Create a chat session with any OpenAI-compatible model.
48+
chat <- chat_openai(
49+
model = Sys.getenv("MODEL", "gpt-4o"),
50+
echo = "output"
51+
)
52+
chat$set_tools(tools)
53+
54+
# Ask a question — the model will call browse_stac_catalog, get_stac_details,
55+
# and query as needed.
56+
chat$chat("What fraction of Australia is protected area?")

examples/agent_langchain.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""
2+
LangChain example: let an LLM decide when to call the duckdb-geo MCP tools.
3+
4+
Uses langchain-mcp-adapters to expose every MCP tool to a LangGraph ReAct
5+
agent. The model picks the tool, generates SQL, and summarises the result.
6+
7+
Install:
8+
pip install langchain-mcp-adapters langchain-openai langgraph
9+
10+
Set:
11+
export OPENAI_API_KEY=... # or use any OpenAI-compatible endpoint
12+
export OPENAI_BASE_URL=... # optional, defaults to OpenAI
13+
14+
Run:
15+
python agent_langchain.py
16+
"""
17+
18+
import asyncio
19+
import os
20+
21+
from langchain_openai import ChatOpenAI
22+
from langchain_mcp_adapters.client import MultiServerMCPClient
23+
from langgraph.prebuilt import create_react_agent
24+
25+
MCP_URL = "https://duckdb-mcp.nrp-nautilus.io/mcp"
26+
QUESTION = "What fraction of Australia is protected area?"
27+
28+
29+
async def main() -> None:
30+
client = MultiServerMCPClient(
31+
{
32+
"duckdb-geo": {
33+
"url": MCP_URL,
34+
"transport": "streamable_http",
35+
}
36+
}
37+
)
38+
tools = await client.get_tools()
39+
40+
model = ChatOpenAI(
41+
model=os.environ.get("MODEL", "gpt-4o"),
42+
max_tokens=4096,
43+
)
44+
agent = create_react_agent(model, tools)
45+
46+
result = await agent.ainvoke(
47+
{"messages": [{"role": "user", "content": QUESTION}]}
48+
)
49+
print(result["messages"][-1].content)
50+
51+
52+
if __name__ == "__main__":
53+
asyncio.run(main())

examples/query.R

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Minimal R example: call the duckdb-geo MCP `query` tool directly.
2+
#
3+
# No LLM involved -- this just speaks MCP over streamable HTTP and runs SQL.
4+
# There is no R MCP client library, so we hit the JSON-RPC endpoint via httr2.
5+
# The server runs in stateless mode, so no session handshake is required.
6+
#
7+
# Install:
8+
# install.packages(c("httr2", "jsonlite"))
9+
#
10+
# Run:
11+
# Rscript query.R
12+
13+
library(httr2)
14+
library(jsonlite)
15+
16+
mcp_url <- "https://duckdb-mcp.nrp-nautilus.io/mcp"
17+
18+
sql <- "
19+
SELECT country, name_en, subtype
20+
FROM read_parquet('s3://public-overturemaps/2026-02-18.0/countries.parquet')
21+
WHERE subtype = 'country' AND is_land
22+
ORDER BY name_en
23+
LIMIT 10
24+
"
25+
26+
# MCP streamable-HTTP responses come back as text/event-stream (SSE) by
27+
# default. Each event is a `data: {...}\n\n` block whose payload is JSON-RPC.
28+
parse_sse <- function(body) {
29+
lines <- strsplit(body, "\n", fixed = TRUE)[[1]]
30+
data_lines <- sub("^data: ", "", lines[grepl("^data: ", lines)])
31+
lapply(data_lines, fromJSON, simplifyVector = FALSE)
32+
}
33+
34+
mcp_call <- function(method, params, id = 1L) {
35+
resp <- request(mcp_url) |>
36+
req_headers(
37+
Accept = "application/json, text/event-stream",
38+
`Content-Type` = "application/json"
39+
) |>
40+
req_body_json(list(
41+
jsonrpc = "2.0",
42+
id = id,
43+
method = method,
44+
params = params
45+
)) |>
46+
req_perform()
47+
48+
body <- resp_body_string(resp)
49+
ctype <- resp_content_type(resp)
50+
if (grepl("event-stream", ctype, fixed = TRUE)) {
51+
msgs <- parse_sse(body)
52+
msgs[[length(msgs)]] # final message holds the result
53+
} else {
54+
fromJSON(body, simplifyVector = FALSE)
55+
}
56+
}
57+
58+
# Call the `query` tool.
59+
resp <- mcp_call("tools/call", list(
60+
name = "query",
61+
arguments = list(sql_query = sql)
62+
))
63+
64+
# Each content block has a `text` field; print them all.
65+
for (block in resp$result$content) {
66+
cat(block$text, "\n")
67+
}

0 commit comments

Comments
 (0)