Shiny apps inside AI chat
shinymcp converts Shiny interfaces and R computations into MCP Apps: interactive cards that can run in chat clients, Shiny hosts, and shinychat conversations.
ui:// resourcesDeclare HTML app surfaces for MCP hosts.
postMessage bridgeKeep inputs, tool calls, and results synchronized.
Shiny hostingEmbed MCP Apps as full-screen-capable Shiny cards.
Use Shiny or bslib inputs, add MCP output targets, and bind them to R tools.
Flatten reactive groups into tool functions while keeping the user-facing workflow.
Return rich MCP cards from chat tools, including state sync and full-screen mode.
You can install the development version of shinymcp from GitHub with:
# install.packages("pak")
pak::pak("JamesHWade/shinymcp")An MCP App has two parts: UI components that render in the chat
interface, and tools that run R code when inputs change. Use
standard shiny or bslib inputs — the bridge auto-detects them by
matching tool argument names to element id attributes.
library(shinymcp)
library(bslib)
ui <- page_sidebar(
theme = bs_theme(preset = "shiny"),
title = "Dataset Explorer",
sidebar = sidebar(
# Standard shiny input — auto-detected because id matches tool arg "dataset"
shiny::selectInput("dataset", "Choose dataset", c("mtcars", "iris", "pressure"))
),
card(
card_header("Summary"),
mcp_text("summary")
)
)
tools <- list(
ellmer::tool(
fun = function(dataset = "mtcars") {
data <- get(dataset, envir = asNamespace("datasets"))
paste(capture.output(summary(data)), collapse = "\n")
},
name = "get_summary",
description = "Get summary statistics for the selected dataset",
arguments = list(
dataset = ellmer::type_string("Dataset name")
)
)
)
app <- mcp_app(ui, tools, name = "dataset-explorer")
serve(app)Save this as app.R, then register it in your Claude Desktop config:
{
"mcpServers": {
"dataset-explorer": {
"command": "Rscript",
"args": ["/path/to/app.R"]
}
}
}Restart Claude Desktop. When the tool is invoked, an interactive UI appears inline in the conversation. Changing the dropdown calls the tool and updates the output — no page reload needed.
If nothing renders, see vignette("debugging-shinymcp") — the most
common cause is a host that doesn’t support the MCP Apps extension yet.
MCP Apps is an optional MCP extension (spec) that hosts must opt into. Your tools degrade gracefully: in any MCP client they work as ordinary text tools; the interactive UI appears in hosts that support MCP Apps.
| Host | Interactive UI |
|---|---|
| Claude Desktop / claude.ai | ✅ (MCP Apps support rolling out) |
| ChatGPT (apps) | ✅ |
preview_app() — local browser preview |
✅ reference host with protocol log |
Shiny / shinychat via mcp_embed() |
✅ |
| Any other MCP client (no apps extension) | text-only tools, still fully functional |
The bridge also reports input state to the model’s context as the user
interacts with the rendered UI (ui/update-model-context). The model can
see what the user changed in the app and act on it in its next turn.
The core idea: flatten your reactive graph into tool functions.
Each connected group of inputs → reactives → outputs becomes a single tool that takes input values as arguments and returns a named list of outputs.
# --- Shiny ---
server <- function(input, output, session) {
filtered <- reactive({
penguins[penguins$species == input$species, ]
})
output$scatter <- renderPlot({
ggplot(filtered(), aes(x, y)) + geom_point()
})
output$stats <- renderPrint({
summary(filtered())
})
}
# --- MCP App tool ---
ellmer::tool(
fun = function(species = "Adelie") {
filtered <- penguins[penguins$species == species, ]
# Render plot to base64 PNG
tmp <- tempfile(fileext = ".png")
ggplot2::ggsave(tmp, my_plot, width = 7, height = 4, dpi = 144)
on.exit(unlink(tmp))
list(
scatter = base64enc::base64encode(tmp),
stats = paste(capture.output(summary(filtered)), collapse = "\n")
)
},
name = "explore",
description = "Filter and visualize penguins",
arguments = list(
species = ellmer::type_string("Penguin species")
)
)Return keys (scatter, stats) must match output IDs in the UI
(mcp_plot("scatter"), mcp_text("stats")). The bridge routes each
value to the correct output element.
For a full worked example converting a Shiny app step-by-step, see
vignette("converting-shiny-apps").
shinymcp includes a parse-analyze-generate pipeline that can scaffold an MCP App from an existing Shiny app:
convert_app("path/to/my-shiny-app")This parses the UI and server code, maps the reactive dependency graph into tool groups, and writes a working MCP App with tools, components, and a server entrypoint. The generated tool bodies contain placeholders that you fill in with the actual computation logic.
For details, see vignette("automatic-conversion").
For complex Shiny apps, shinymcp ships a
deputy skill that guides an AI
agent through the conversion process. The skill handles dynamic UI,
modules, file uploads, and other patterns that require human judgment.
See inst/skills/convert-shiny-app/SKILL.md for the full instructions.
The bridge auto-detects standard form elements (<select>, <input>,
etc.) whose id matches a tool argument name. This means you can use
the Shiny and bslib inputs you already know:
sidebar(
shiny::selectInput("species", "Species", choices),
shiny::numericInput("n", "Count", value = 10),
shiny::checkboxInput("trend", "Show trend line")
)For edge cases (id doesn’t match arg name, custom widgets), use
mcp_input() to explicitly mark an element:
mcp_input(shiny::radioButtons("fmt", "Format", c("summary", "head")), id = "fmt")shinymcp also provides lightweight mcp_select(), mcp_text_input(),
etc. that generate minimal HTML without Shiny’s JS runtime. These are
useful for simple apps and are what the automatic conversion pipeline
generates.
| Shiny | shinymcp | Notes |
|---|---|---|
textOutput() / verbatimTextOutput() |
mcp_text() |
Renders in <pre> with monospace font |
plotOutput() |
mcp_plot() |
Tool returns base64-encoded PNG |
tableOutput() |
mcp_table() |
Tool returns HTML table string |
htmlOutput() |
mcp_html() |
Tool returns raw HTML |
You can also turn any tag into an output target with mcp_output():
mcp_output(tags$pre(id = "result"), type = "text")MCP hosts apply a restrictive Content Security Policy to the app iframe: external scripts, stylesheets, fonts, and network requests are blocked by default, and they fail silently. shinymcp sidesteps this by inlining all CSS/JS dependencies into the HTML resource and sending plots as base64 — stick to that model and you never hit it. If your app genuinely needs external domains (a CDN-loaded htmlwidget, client-side API calls), declare them so hosts can allow them:
mcp_app(
ui, tools,
name = "my-app",
csp = list(
connect_domains = "https://api.example.com",
resource_domains = "https://cdn.jsdelivr.net"
)
)See vignette("mcp-apps-protocol") for details.
shinymcp ships an example ladder: each app adds one new idea, so you can work up from a 30-line hello-world to a full embedded-governance demo.
# 1. The whole contract in ~30 lines: one input, one tool, one output
system.file("examples", "hello-mcp", "app.R", package = "shinymcp")
# 2. Native shiny/bslib inputs, auto-detected by id — no wrappers
system.file("examples", "bslib-inputs", "app.R", package = "shinymcp")
# 3. A real dashboard: Palmer Penguins with ggplot2, multiple outputs,
# and a declared outputSchema
system.file("examples", "penguins", "app.R", package = "shinymcp")
# 4. Protocol feature tour: app-only tools, lazy-loaded resources, theme
# syncing, and the window.shinymcp host-interaction API
system.file("examples", "feature-tour", "app.R", package = "shinymcp")
# 5. Several tools in one app — reactive groups become separate contracts
system.file("examples", "multi-tool", "app.R", package = "shinymcp")
# 6. Realistic chat cards: revenue planning, experiment design, incident
# triage, plus a shinychat app that hosts them as rich tool cards
system.file("examples", "use-cases", "app.R", package = "shinymcp")
system.file("examples", "use-cases", "shinychat-app.R", package = "shinymcp")Every example runs in preview_app() (a local reference host with a
protocol log) or as an MCP server via Rscript app.R. The guided tour
with what-to-look-for notes is in vignette("use-cases").
MCP Apps render inside sandboxed iframes in the AI chat interface. A lightweight JavaScript bridge (no npm dependencies) handles communication via postMessage/JSON-RPC:
- User changes an input → bridge auto-detects which form elements are inputs (by matching tool argument names to element ids) and collects all values
- Bridge pushes the input state into the model’s context
(
ui/update-model-context) and sends atools/callrequest to the host - Host proxies the call to the MCP server (your R process)
- R tool function runs, returns results
- Bridge updates output elements with the response
The bridge also implements the MCP Apps initialization handshake
(ui/initialize), host theme syncing (light/dark mode follows the chat
client automatically), auto-resize notifications, ping health checks,
and graceful teardown.
shinymcp targets the MCP Apps spec version 2026-01-26. For a
message-level walkthrough, a compliance table, and the window.shinymcp
JS API for custom host interactions (open links, send chat messages,
request fullscreen), see vignette("mcp-apps-protocol").
