
Converting Shiny Apps to MCP Apps
Source:vignettes/converting-shiny-apps.Rmd
converting-shiny-apps.RmdAn MCP App is an interactive UI that renders inside an AI chat interface like Claude Desktop. A Shiny app runs in a browser tab backed by a persistent server. An MCP App is a small HTML document, and user interactions trigger tool calls: stateless R functions that the AI host invokes on demand.
This vignette converts a Shiny app into an MCP App with shinymcp, using the classic Palmer Penguins explorer as the worked example.
Key differences from Shiny
Before converting, it helps to understand what changes:
| Shiny | MCP App | |
|---|---|---|
| Runs in | Browser tab | AI chat interface (iframe) |
| Server | Persistent R process with reactive graph | Stateless tool calls |
| Reactivity |
reactive(), observe(),
render*()
|
Tool function called on input change |
| UI framework | shiny.js + WebSocket | Plain HTML + postMessage bridge |
| State | Server-side reactive values | Recomputed each tool call |
| Layout |
fluidPage(), sidebarLayout()
|
bslib (page_sidebar(), card()) |
The work of converting an app is flattening your reactive graph into tool functions. Each connected group of inputs → reactives → outputs becomes a single tool that takes the inputs as arguments and returns the outputs as a named list.
Step 1: Identify inputs and outputs
Start with your Shiny app and list every input and output. Here’s a typical penguins Shiny app:
# --- Original Shiny app ---
library(shiny)
library(ggplot2)
library(palmerpenguins)
ui <- fluidPage(
titlePanel("Palmer Penguins Explorer"),
sidebarLayout(
sidebarPanel(
selectInput("species", "Species", c("All", "Adelie", "Chinstrap", "Gentoo")),
selectInput("x_var", "X axis", c("bill_length_mm", "bill_depth_mm",
"flipper_length_mm", "body_mass_g")),
selectInput("y_var", "Y axis", c("bill_depth_mm", "bill_length_mm",
"flipper_length_mm", "body_mass_g")),
checkboxInput("trend", "Show trend line", FALSE)
),
mainPanel(
plotOutput("scatter"),
verbatimTextOutput("stats")
)
)
)
server <- function(input, output, session) {
filtered_data <- reactive({
data <- penguins[complete.cases(penguins), ]
if (input$species != "All") {
data <- data[data$species == input$species, ]
}
data
})
output$scatter <- renderPlot({
p <- ggplot(filtered_data(),
aes(x = .data[[input$x_var]], y = .data[[input$y_var]],
color = species)) +
geom_point(alpha = 0.7, size = 2.5)
if (input$trend) p <- p + geom_smooth(method = "lm", se = FALSE)
p
})
output$stats <- renderPrint({
summary(filtered_data()[, c(input$x_var, input$y_var, "species")])
})
}
shinyApp(ui, server)This app has four inputs: a selectInput for
species, two more for x_var and
y_var, and a checkboxInput for
trend. It has two outputs: a plotOutput named
scatter holding a ggplot, and a
verbatimTextOutput named stats holding summary
statistics.
The reactive logic is one piece. filtered_data() filters
penguins by species, and both outputs depend on it together with the
axis and trend inputs. Everything is connected, so it maps to a single
tool.
Step 2: Map components
The JS bridge auto-detects inputs by matching tool argument names to
element id attributes. You can keep your Shiny inputs as
they are, as long as the id matches a tool argument
name:
| Shiny input | What to do |
|---|---|
selectInput("species", "Species", choices) |
Keep it. Auto-detected by id="species"
|
selectInput("x_var", "X axis", choices) |
Keep it. Auto-detected by id="x_var"
|
checkboxInput("trend", "Show trend line") |
Keep it. Auto-detected by id="trend"
|
For outputs, replace Shiny outputs with shinymcp equivalents:
| Shiny output | shinymcp |
|---|---|
plotOutput("scatter") |
mcp_plot("scatter") |
verbatimTextOutput("stats") |
mcp_text("stats") |
The IDs stay the same. If you need to mark a non-standard element as
an output, use mcp_output(tag, id, type).
Step 3: Build the UI
Use bslib for layout instead of Shiny’s fluidPage() /
sidebarLayout(). bslib components work directly with
htmltools, with no Shiny server. Standard
shiny::selectInput(), shiny::checkboxInput(),
and the rest are auto-detected by the bridge:
library(shinymcp)
library(bslib)
library(htmltools)
ui <- page_sidebar(
theme = bs_theme(preset = "shiny"),
title = "Palmer Penguins Explorer",
sidebar = sidebar(
width = 260,
shiny::selectInput(
"species", "Species",
c("All", "Adelie", "Chinstrap", "Gentoo")
),
shiny::selectInput("x_var", "X axis", c(
"Bill Length (mm)" = "bill_length_mm",
"Bill Depth (mm)" = "bill_depth_mm",
"Flipper Length (mm)" = "flipper_length_mm",
"Body Mass (g)" = "body_mass_g"
)),
shiny::selectInput("y_var", "Y axis", c(
"Bill Depth (mm)" = "bill_depth_mm",
"Bill Length (mm)" = "bill_length_mm",
"Flipper Length (mm)" = "flipper_length_mm",
"Body Mass (g)" = "body_mass_g"
)),
shiny::checkboxInput("trend", "Show trend line")
),
card(
card_header("Scatter Plot"),
mcp_plot("scatter", height = "380px")
),
card(
card_header("Summary Statistics"),
mcp_text("stats")
)
)Named choice vectors
(e.g. "Bill Length (mm)" = "bill_length_mm") work exactly
like Shiny: the name is displayed, the value is sent to the tool.
Step 4: Convert reactive logic to a tool
Flatten the reactive graph into a single function. The arguments are the input values, each with a sensible default. The body does what the reactives and renderers did. The function returns a named list that maps output IDs to values.
For plots, render to a temporary PNG and return the base64-encoded
image. The bridge displays it as an <img>
element.
tools <- list(
ellmer::tool(
fun = function(
species = "All",
x_var = "bill_length_mm",
y_var = "bill_depth_mm",
trend = FALSE
) {
data <- palmerpenguins::penguins
data <- data[complete.cases(data), ]
# Filter (was: filtered_data reactive)
if (species != "All") {
data <- data[data$species == species, ]
}
# Plot (was: renderPlot)
p <- ggplot2::ggplot(
data,
ggplot2::aes(x = .data[[x_var]], y = .data[[y_var]], color = species)
) +
ggplot2::geom_point(alpha = 0.7, size = 2.5) +
ggplot2::theme_minimal(base_size = 13)
if (isTRUE(trend)) {
p <- p + ggplot2::geom_smooth(method = "lm", se = FALSE)
}
# Render plot to base64 PNG
tmp <- tempfile(fileext = ".png")
ggplot2::ggsave(tmp, p, width = 7, height = 4, dpi = 144, bg = "white")
on.exit(unlink(tmp))
plot_b64 <- base64enc::base64encode(tmp)
# Summary text (was: renderPrint)
stats <- paste(capture.output({
cat(sprintf("Observations: %d penguins\n\n", nrow(data)))
print(summary(data[, c(x_var, y_var, "species")]))
}), collapse = "\n")
# Return named list: keys must match output IDs
list(scatter = plot_b64, stats = stats)
},
name = "explore_penguins",
description = "Explore the Palmer Penguins dataset with scatter plots",
arguments = list(
species = ellmer::type_string("Species filter: All, Adelie, Chinstrap, or Gentoo"),
x_var = ellmer::type_string("X axis variable name"),
y_var = ellmer::type_string("Y axis variable name"),
trend = ellmer::type_boolean("Whether to show a linear trend line")
)
)
)A few things are worth getting right here.
The return keys match the output IDs.
list(scatter = ..., stats = ...) corresponds to
mcp_plot("scatter") and mcp_text("stats"), and
the bridge uses these keys to route values to the right output
elements.
Plots travel as base64. Use ggsave() or
png() plus dev.off(), then
base64enc::base64encode(), and the bridge wraps the result
in an <img> tag. Text output renders in a
<pre> tag, so the column alignment from R’s
summary() and cat() survives.
Every argument needs a default value. The tool uses the defaults when it first loads, and they tell the AI what format to expect.
Tool calls are stateless: no reactiveVal(), no
<<-, no session state. Each call recomputes from
scratch, which is fine for most apps because the calls are fast.
Step 5: Assemble and serve
Wire up the UI, tools, and server:
Save this as a single app.R file. To register it with
Claude Desktop, add an entry to your Claude Desktop config
(~/Library/Application Support/Claude/claude_desktop_config.json
on macOS):
{
"mcpServers": {
"penguins": {
"command": "/opt/homebrew/bin/Rscript",
"args": ["/path/to/your/app.R"]
}
}
}Restart Claude Desktop, and the app will appear as an interactive UI
when the explore_penguins tool is called.
Conversion patterns
One reactive group → one tool
If all your outputs share the same reactive dependencies, they belong in a single tool. This is the most common case:
# Shiny: two outputs from one reactive
server <- function(input, output, session) {
data <- reactive({ mtcars[mtcars$cyl == input$cyl, ] })
output$plot <- renderPlot({ plot(data()$mpg) })
output$text <- renderText({ paste(nrow(data()), "cars") })
}
# MCP App: one tool returning both
ellmer::tool(
fun = function(cyl = 4) {
data <- mtcars[mtcars$cyl == cyl, ]
list(
plot = render_plot_base64(function() plot(data$mpg)),
text = paste(nrow(data), "cars")
)
},
# ...
)Independent outputs → multiple tools
If outputs have completely independent inputs, use separate tools. The bridge calls all tools whose inputs changed:
Replacing reactiveVal / session state
MCP tool calls are stateless. If your Shiny app uses
reactiveVal() or reactiveValues() to
accumulate state across interactions, you have two options. Most
filtering and selection state can be derived from the current input
values alone, so recompute it. For state you genuinely have to carry,
like a todo list, write it to a temp file and read it back on the next
tool call.
Tables
For tableOutput, return an HTML table string.
knitr::kable() is the easiest approach:
fun = function(dataset = "mtcars") {
data <- head(get(dataset, envir = asNamespace("datasets")), 10)
list(
my_table = knitr::kable(data, format = "html")
)
}Use mcp_table("my_table") in the UI. The bridge sets
innerHTML directly.
Dynamic UI
MCP Apps don’t support renderUI() /
uiOutput(). Instead:
- Use
mcp_html(id)and return HTML strings from the tool - For show/hide, return an empty string when the element should be hidden
- For dynamic choices, use a fixed
mcp_select()and document valid values in the tool description
Layout with bslib
Since MCP Apps use htmltools directly (not Shiny’s
fluidPage), use bslib for layout. Common patterns:
# Sidebar layout (standard shiny inputs auto-detected)
page_sidebar(
sidebar = sidebar(
shiny::selectInput("x", "Variable", choices),
shiny::checkboxInput("log", "Log scale")
),
card(mcp_plot("main_plot")),
card(mcp_text("summary"))
)
# Multi-column layout
page(
theme = bs_theme(preset = "shiny"),
layout_columns(
col_widths = c(6, 6),
card(card_header("Plot"), mcp_plot("plot1")),
card(card_header("Table"), mcp_table("table1"))
)
)
# Stacked cards
page(
card(card_header("Controls"), shiny::selectInput("x", "Pick", c("a", "b"))),
card(card_header("Output"), mcp_text("result"))
)All CSS and JS dependencies from bslib are automatically inlined into the MCP App’s HTML resource.
What can’t be converted
Some Shiny features don’t have MCP App equivalents:
-
fileInput: MCP Apps can’t receive file uploads. Usemcp_text_input()for file paths instead, or have the tool read from a known directory. - Shiny modules with internal state: Flatten the module logic into top-level tools.
-
invalidateLater/ polling: MCP tools are request-response only. No background updates. -
JavaScript-heavy widgets (DT, plotly, leaflet):
These require Shiny’s JS runtime. Use static alternatives (base R plots,
knitr::kabletables).
Full example
The complete converted penguins app is available at:
system.file("examples", "penguins", "app.R", package = "shinymcp")