shinymcp includes an automatic conversion pipeline that translates Shiny apps into MCP Apps. It parses the UI and server code, analyzes the reactive dependency graph, and generates a working MCP App with tools, components, and a server entrypoint.
This vignette covers the convert_app() pipeline and each
of its stages.
Quick start
Point convert_app() at a Shiny app directory:
library(shinymcp)
convert_app("path/to/my-shiny-app")── Converting Shiny app to MCP App ────────────────────
Source: path/to/my-shiny-app
Output: path/to/my-shiny-app_mcp
── Parsing
ℹ Found 4 input(s) and 2 output(s)
ℹ Complexity: medium
── Analyzing
ℹ Identified 1 tool group(s)
── Generating
✔ Generated MCP App in path/to/my-shiny-app_mcp
The generated directory contains:
| File | Purpose |
|---|---|
app.R |
Entrypoint that wires up UI and tools |
ui.R |
UI built with shinymcp components |
tools.R |
ellmer::tool() definitions for each reactive group |
server.R |
Server setup with state environment |
CONVERSION_NOTES.md |
Review notes (complex apps only) |
The pipeline
convert_app() runs three stages in sequence:
parse_shiny_app() → analyze_reactive_graph() → generate_mcp_app()
You can run each stage independently for finer control.
Stage 1: Parse
parse_shiny_app() reads the app’s R source and extracts
a structured intermediate representation (IR):
ir <- parse_shiny_app("path/to/my-shiny-app")
ir── Shiny App IR ───────────────────────────────────────
Path: path/to/my-shiny-app
Inputs: 4
Outputs: 2
Reactives: 1
Observers: 0
Complexity: medium
Input refs: species, x_var, y_var, trend
The parser handles both app.R (single-file) and
ui.R/server.R (split-file) apps. It walks the
AST to find every *Input() call (with its id, type, label,
and arguments), every *Output() call (with its id and
type), the body of the server function, each
reactive() expression and the inputs it depends on, each
observe() and observeEvent() call, and every
input$name and input[["name"]] reference.
The parser classifies apps by complexity:
| Complexity | Criteria |
|---|---|
| simple | Up to 3 inputs, no reactives, no observers |
| medium | Up to 8 inputs, up to 3 reactives |
| complex | Everything else |
Stage 2: Analyze
analyze_reactive_graph() takes the IR and builds a
dependency graph:
analysis <- analyze_reactive_graph(ir)
analysis── Reactive Analysis ──────────────────────────────────
Nodes: 7
Edges: 6
Tool groups: 1
The analyzer builds a dependency graph whose nodes are inputs,
reactives, and outputs, with edges for data flow: input$x
used in reactive(...) used in renderPlot(...).
It then runs union-find to find connected components, grouping nodes
that are transitively connected. Each connected component becomes one
tool, with the component’s inputs as arguments and its outputs as return
values. Along the way it flags the patterns it can’t resolve: dynamic
UI, file uploads, download handlers, and observers with side
effects.
So a Shiny app where a reactive() feeds both
renderPlot() and renderText() produces a
single tool group that holds all the related inputs and both
outputs.
Stage 3: Generate
generate_mcp_app() writes the MCP App files:
generate_mcp_app(analysis, ir, output_dir = "my-app-mcp")It generates the UI code using shinymcp components
(mcp_select(), mcp_plot(), and so on) mapped
from the original Shiny inputs and outputs. It writes the tool
definitions as ellmer::tool() calls with typed arguments
(type_string, type_number,
type_boolean) and tool annotations. It writes a server
entrypoint that sources the tools and sets up a state environment. For
complex apps, it also writes conversion notes listing what needs manual
review.
What gets converted automatically
The pipeline handles these Shiny patterns automatically:
| Shiny pattern | MCP App equivalent |
|---|---|
selectInput() |
mcp_select() |
textInput() |
mcp_text_input() |
numericInput() |
mcp_numeric_input() |
checkboxInput() |
mcp_checkbox() |
sliderInput() |
mcp_slider() |
radioButtons() |
mcp_radio() (or mcp_select()) |
actionButton() |
mcp_action_button() |
plotOutput() |
mcp_plot() with base64 PNG rendering |
textOutput() / verbatimTextOutput()
|
mcp_text() |
tableOutput() |
mcp_table() |
htmlOutput() / uiOutput()
|
mcp_html() |
reactive() chains |
Flattened into tool function body |
app.R (single file) |
Fully supported |
ui.R / server.R (split files) |
Fully supported |
What needs manual review
The generated tools contain placeholder function bodies. You copy the
real computation from the original render*() functions into
the generated tool functions.
For simple apps, this is quick: the tool arguments match the original inputs, and the return structure matches the outputs. For complex apps, review these areas.
Reactive chain logic
The generator creates the tool skeleton but uses placeholder code
like paste("Result for:", x). Replace this with the actual
computation:
# Generated placeholder:
update_scatter_and_stats <- ellmer::tool(
fun = function(species, x_var, y_var, trend) {
# TODO: Insert computation logic from original render function here
paste("Result for:", species, x_var, y_var, trend)
},
# ...
)
# After manual review, fill in the real logic:
update_scatter_and_stats <- ellmer::tool(
fun = function(species = "All", x_var = "bill_length_mm",
y_var = "bill_depth_mm", trend = FALSE) {
data <- palmerpenguins::penguins[complete.cases(palmerpenguins::penguins), ]
if (species != "All") data <- data[data$species == species, ]
p <- ggplot2::ggplot(data, ggplot2::aes(.data[[x_var]], .data[[y_var]])) +
ggplot2::geom_point()
if (isTRUE(trend)) p <- p + ggplot2::geom_smooth(method = "lm", se = FALSE)
tmp <- tempfile(fileext = ".png")
ggplot2::ggsave(tmp, p, width = 7, height = 4, dpi = 144, bg = "white")
on.exit(unlink(tmp))
list(
scatter = base64enc::base64encode(tmp),
stats = paste(capture.output(summary(data)), collapse = "\n")
)
},
# ...
)Dynamic UI
uiOutput() / renderUI() is flagged as a
warning. Replace with mcp_html() and return HTML strings
from the tool:
# Instead of renderUI(), return HTML from the tool:
fun = function(show_detail = FALSE) {
html <- if (show_detail) {
"<div><h3>Details</h3><p>More info here</p></div>"
} else {
""
}
list(detail_panel = html)
}Observers and side effects
observe() and observeEvent() calls are
flagged since they often perform side effects (database writes, file
operations) that don’t map cleanly to stateless tool calls. Review each
observer and decide:
- Can it be folded into a tool’s return value?
- Does it need to write to the file system? (Use temp files)
- Can it be removed entirely?
File uploads
fileInput() is not supported. The generator warns about
this. Use mcp_text_input() for file paths instead, or have
the tool read from a known directory.
Controlling output
By default, convert_app() writes to
{path}_mcp/. Specify a custom output directory:
convert_app("my-app", output_dir = "output/my-mcp-app")After conversion
Once you’ve filled in the tool bodies, test locally with
serve(app) to check that the UI renders and the tools
respond. Register the app with Claude Desktop by adding it to your
config. From there you iterate: adjust layouts with bslib, and tweak the
tool descriptions so the model calls them well.
For a detailed walkthrough of building an MCP App from scratch
(without the automatic pipeline), see
vignette("converting-shiny-apps").
