The original MCP server shipped 9 tools that mostly serialized raw data rows as JSON and sent them to Claude. This created three compounding problems:
get_dataset call on a
moderately large table (e.g., Grade Results with 50K rows) would hit the
MCP protocol’s 1MB response limit, forcing artificial row caps.student_summary and course_summary hard-coded
specific analysis patterns. Any question outside those patterns required
a new tool or awkward workarounds.The redesign inverts the architecture: Claude writes R code, the server executes it in a persistent workspace, and returns compact results – text summaries and file-based visualizations.
R is purpose-built for data manipulation. Claude is purpose-built for
reasoning about what manipulation to do. The execute_r tool
bridges these: Claude writes a dplyr pipeline, ggplot visualization, or
statistical test; the server evaluates it and returns the result. No raw
data crosses the wire unless explicitly requested.
The preferred visualization path uses Chart.js injected into self-contained HTML files:
execute_r aggregates data in R (counts, means,
distributions)writeLines() saves it to the output directoryexecute_r → aggregate data → paste0(Chart.js HTML) → writeLines()
↓
Interactive chart in browser (tooltips, hover, responsive)
As a fallback for cases where the user explicitly requests a PNG or a
chart type Chart.js cannot handle, execute_r supports
returning ggplot objects directly. When detected, the server renders the
plot to a PNG file and generates an HTML viewer wrapper. Both files are
saved to the configured output directory.
The old server had tools organized around API operations:
get_dataset, join_datasets,
student_summary. The redesign organizes around user
goals:
| Old (9 tools) | New (7 tools) | Why |
|---|---|---|
get_dataset |
execute_r |
Claude writes
bs_get_dataset("Users") %>% head(10) |
student_summary |
execute_r |
Claude writes the exact summary needed |
course_summary |
execute_r |
Claude writes the exact aggregation |
join_datasets |
execute_r |
Claude calls bs_join() directly |
describe_dataset |
describe_dataset (enhanced) |
Now returns column stats, not sample rows |
| – | get_data_summary |
Quick filter/group stats without writing R |
Every response from describe_dataset and
get_data_summary ends with a hint:
“Use execute_r for custom analysis. Available: bs_get_dataset(), bs_join(), dplyr verbs.”
This teaches the model (and the user reading the transcript) that there’s a more powerful tool available when the pre-built summaries aren’t enough.
The MCP initialize handshake includes an
instructions field – a block of text that tells Claude the
recommended workflow before any tools are called:
1. Use list_datasets or search_datasets to discover data
2. Use describe_dataset to understand columns and distributions
3. Use get_data_summary with filter_by/group_by for quick stats
4. Use execute_r for custom analysis
The instructions also include performance guidelines, the Chart.js HTML pattern, available packages (dplyr, tidyr, ggplot2, lubridate, scales), and the date axis gotcha for ggplot2.
The server creates a .mcp_workspace environment at
startup with dplyr, tidyr,
ggplot2, lubridate, and scales
pre-loaded. Every execute_r call evaluates code in this
environment, so variables persist between calls:
# Call 1: load and filter
users <- bs_get_dataset("Users") %>% filter(role_name == "Student")
# Call 2: uses `users` from previous call
users %>% count(org_unit_id, sort = TRUE)The workspace also exposes output_dir – the path to the
configured output directory – so Chart.js HTML files can be written
there directly.
Visualizations are written to a configurable output directory. Resolution order:
BRIGHTSPACER_OUTPUT_DIR environment variable (if
set)<package_root>/brightspaceR_output (development
mode)<working_directory>/brightspaceR_output
(fallback)<temp_dir>/brightspaceR_output (last resort if
directory creation fails)The path is normalised with normalizePath() and logged
at startup. If creation fails, the server falls back gracefully and logs
a warning.
handle_execute_r inspects the result type and formats
accordingly:
| Result type | Formatting |
|---|---|
data.frame with <=50 rows |
Full text table via print() |
data.frame with >50 rows |
head(20) + “… N more rows” hint |
gg/ggplot object |
Render to 900x600 PNG + HTML viewer, return file paths |
Character ending in
.html/.png/.pdf/.csv |
Return as file path |
| Anything else | capture.output(print(x)) |
| Error | isError: TRUE with the error message |
summarize_column() is a shared function used by both
describe_dataset and get_data_summary. It
produces type-appropriate stats:
This replaces the old approach of sending 5 sample rows, which told Claude almost nothing about the data distribution.
Composable functions build MCP-compliant responses:
mcp_text(text, audience) – Creates a
TextContent block with optional audience
annotation (["user"] for clean output,
["assistant"] for verbose diagnostics like row counts and
join warnings).mcp_result(contents, is_error) – Wraps a list of
content blocks into a tool result. Handles size truncation at
~800KB.The mcp_result() function checks the serialized JSON
size of every response. If it exceeds 800KB, it finds the first text
content block and truncates it with a clear message:
... [TRUNCATED: response exceeded size limit.
Use head()/filter() to narrow results in execute_r.]
This is a safety net, not the primary size control. The real size
control is that execute_r returns computed results (a
count, a mean, a chart file path) rather than raw data.
execute_r includes several layers of protection against
runaway queries:
30-second timeout: Every eval is wrapped in
setTimeLimit(elapsed = 30). If a Cartesian join or
unfiltered aggregation over millions of rows hangs, the server kills it
and returns a clear error: “Execution timed out after 30 seconds.
Try filtering data earlier or breaking into smaller steps.”
Row-count reporting: The workspace’s
bs_get_dataset() wrapper emits a message like
[Grade Results: 523,041 rows x 12 cols] every time it’s
called. These messages are captured and returned to Claude as
assistant-facing context (using audience: ["assistant"]),
so Claude always knows how big the data it just loaded is – and can
course-correct before the next operation.
Large-dataset warnings: If a dataset exceeds 50K
rows, the message includes an explicit warning: “WARNING: large
dataset – filter early to avoid slow operations”. The same applies
to bs_join() when either input exceeds 50K rows.
Server instructions: The initialize
response tells Claude to always call describe_dataset first
to check row counts, filter early on large tables, and never return raw
unfiltered data frames.
These defenses are layered: instructions guide Claude to do the right thing, row-count messages let it self-correct mid-execution, and the timeout is the hard backstop.
list_datasets – Returns all available
BDS dataset names and descriptions. No parameters.
search_datasets – Case-insensitive
keyword search across dataset names and descriptions.
auth_status – Reports whether the
server has a valid Brightspace OAuth token.
list_schemas – Lists registered dataset
schemas and their key columns (used by bs_join()).
describe_dataset(name) – Loads a
dataset and returns per-column summary statistics. The footer suggests
using execute_r for custom queries.
get_data_summary(dataset, filter_by?, group_by?)
– The “quick stats” tool. Loads a dataset, optionally filters by
column-value pairs, optionally groups. Without grouping, returns
per-column stats. With grouping, returns group counts and numeric column
means per group. Always suggests execute_r for anything
more complex.
execute_r(code) – The core tool. Claude
writes R code as a string, the server evaluates it in the persistent
workspace, and returns the result. Handles data frames, ggplot objects
(saved as PNG + HTML), file paths, and arbitrary R objects.
The MCP server is designed for the kinds of questions LMS administrators and institutional researchers typically ask. Here are the main categories with example prompts.
Each of these can be handled through the MCP workflow: discover datasets, check column distributions, then write R code that aggregates, joins, and visualizes.
User: "How are students performing in STAT101?"
Claude thinks: I need to find the course, get grade data, and summarize it.
1. search_datasets(keyword="grade")
-> Finds "Grade Results"
2. describe_dataset(name="Grade Results")
-> Sees columns: user_id, org_unit_id, points_numerator, etc.
-> Sees row count: 523,041
3. execute_r(code="""
grades <- bs_get_dataset("Grade Results")
orgs <- bs_get_dataset("Org Units")
stat101 <- orgs %>% filter(grepl("STAT101", name))
grades %>%
filter(org_unit_id %in% stat101$org_unit_id) %>%
summarise(
n_students = n_distinct(user_id),
mean_grade = mean(points_numerator, na.rm = TRUE),
median_grade = median(points_numerator, na.rm = TRUE)
)
""")
-> Returns: tibble with 1 row of summary stats
4. execute_r(code="""
# Interactive chart
grade_dist <- grades %>%
filter(org_unit_id %in% stat101$org_unit_id,
!is.na(points_numerator)) %>%
mutate(bracket = cut(points_numerator,
breaks = seq(0, 100, 10), include.lowest = TRUE)) %>%
count(bracket)
html <- paste0('<!DOCTYPE html>...<script src=\"https://cdn.jsdelivr.net/npm/chart.js\"></script>...')
writeLines(html, file.path(output_dir, 'stat101_grades.html'))
browseURL(file.path(output_dir, 'stat101_grades.html'))
""")
-> Opens interactive bar chart in browser
Note how calls 3 and 4 share variables (grades,
orgs, stat101) via the persistent workspace.
Claude computes with R, then builds Chart.js HTML for interactive
rendering.
execute_r evaluates arbitrary R code. This is
intentional – the server runs locally, authenticated with the user’s own
Brightspace credentials. The trust boundary is the same as opening an R
console. The workspace is sandboxed only in the sense that it’s a child
environment of globalenv(), not a separate process.
For production deployments where multiple users share a server,
execute_r would need process-level isolation (e.g., callr).
That’s out of scope for the current single-user design.