Skip to contents

An 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:

app <- mcp_app(ui, tools, name = "penguins-explorer")
serve(app)

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:

# Tool 1: only uses the "dataset" input
ellmer::tool(
  fun = function(dataset = "mtcars") { ... },
  name = "summarize_data",
  # ...
)

# Tool 2: only uses the "n" input
ellmer::tool(
  fun = function(n = 100) { ... },
  name = "generate_sample",
  # ...
)

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. Use mcp_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::kable tables).

Full example

The complete converted penguins app is available at:

system.file("examples", "penguins", "app.R", package = "shinymcp")