as_mcp_app() serves a Shiny app you already have as an
MCP App, without changes: its server function keeps running in R. This
article covers what happens when the model opens the app and while
someone uses it, how to decide what the model sees and knows, and what
the page can do.
Shiny is gaining MCP support of its own (rstudio/shiny#4407),
which runs Shiny’s own JavaScript against a real session, where shinymcp
has stand-ins for both. Once it is released, it will be the way to serve
a live app, and shinymcp will stop doing so; this article will then
cover moving an app over. shinymcp will still show such apps in Shiny
apps and shinychat conversations, through mcp_client(); see
vignette("hosting"). Until then, as_mcp_app()
works as described here, and it gets bug fixes but no new features. For
something the model should be able to use on its own, rewrite that part
of the app as tools instead; see
vignette("rewriting-as-tools").
library(shiny)
library(shinymcp)
ui <- fluidPage(
selectInput("cyl", "Cylinders", c(4, 6, 8)),
sliderInput("bins", "Bins", min = 5, max = 30, value = 10),
plotOutput("hist"),
textOutput("summary")
)
server <- function(input, output, session) {
cars <- reactive(mtcars[mtcars$cyl == input$cyl, ])
output$hist <- renderPlot({
hist(cars()$mpg, breaks = input$bins, main = NULL, xlab = "Miles per gallon")
})
output$summary <- renderText({
sprintf("%d cars with %s cylinders; median %.1f mpg.", nrow(cars()), input$cyl, median(cars()$mpg))
})
}
app <- as_mcp_app(
shinyApp(ui, server),
name = "mileage",
description = "Fuel economy of the cars in mtcars, by number of cylinders."
)
app
#> <McpApp> mileage 0.1.0
#> Fuel economy of the cars in mtcars, by number of cylinders.
#> UI resource: ui://mileage
#> Runs the Shiny app's server function, one session per view.
#> Tools:
#> * mileage
#> * mileage_view (app only)Views and sessions
The app has two tools. The model calls mileage. Each
call opens a view: shinymcp starts a session of the app’s
server function, sets its inputs from the call’s arguments, lets it run,
and returns what the outputs show. The chat client then shows the page,
and from there the person using it works with the app directly. Their
changes go to that view’s session through mileage_view, a
tool only the page can call, and the outputs that changed come back.
So the server function runs as it would for a browser: reactive
expressions cache, observers fire, updateSelectInput() and
its relatives change the page, and validate() and
req() behave as usual. There is one session per view, so
two views of the same app don’t share state, as two browser tabs
wouldn’t.
When the model calls mileage again, it opens another
view. To change a view that is already open, it passes that view’s id as
view; the person then sees the same view update, and inputs
the model leaves out keep their current values.
What the model sees
The model’s tool takes the app’s inputs as arguments, described from the UI: a select input becomes a list of allowed values, a slider a bounded number, a date input a date.
str(app$tool_definitions()[[1]]$inputSchema$properties$bins)
#> List of 4
#> $ type : chr "number"
#> $ description: chr "Bins (min 5, max 30). Default: 10."
#> $ minimum : num 5
#> $ maximum : num 30When it calls the tool, the result starts with text written for the model, and carries the same facts as structured data:
result <- app$run_tool("mileage", list(cyl = "6", bins = 5))
cat(result$content[[1]]$text)
#> The mileage app is open in the conversation (view view-b0cfafaee5dfca80).
#>
#> Inputs: cyl = "6"; bins = 5.
#>
#> hist: A plot (640 x 400).
#>
#> summary: 7 cars with 6 cylinders; median 19.7 mpg.
str(result$structuredContent$outputs)
#> List of 2
#> $ hist : chr "A plot (640 x 400)."
#> $ summary: chr "7 cars with 6 cylinders; median 19.7 mpg."Plots also go to the model as images, if it can read them. Set
images = FALSE in as_mcp_app() to leave them
out and save tokens.
Everything the page needs to draw the outputs travels in the result’s
_meta, which chat clients pass to the page and keep from
the model.
Write a description that says what the app is for. The
model reads it when it decides whether to open the app, and a sentence
about the purpose (“fuel economy by number of cylinders”) helps more
than the list of inputs shinymcp writes by default.
Choosing inputs and outputs
A large app makes for a tool with many arguments, most of them beside
the point. Mark the ones that matter with bindMcp():
ui <- fluidPage(
selectInput("cyl", "Cylinders", c(4, 6, 8)) |> bindMcp(),
sliderInput("bins", "Bins", min = 5, max = 30, value = 10),
plotOutput("hist"),
textOutput("summary") |> bindMcp()
)
marked <- as_mcp_app(shinyApp(ui, server), name = "mileage")
names(marked$tool_definitions()[[1]]$inputSchema$properties)
#> [1] "cyl" "view"Once anything is marked, only marked inputs are arguments and only marked outputs are reported. The person using the app still sees and uses all of it. Action buttons are only for the model to press when they are marked, and password and file inputs never are.
What the model knows afterwards
When the person changes something, the page tells the model on its
next turn: by default, the current values of the inputs it can set and a
short summary of each output it reads. To say something more useful,
call mcp_model_context() from the server function:
server <- function(input, output, session) {
cars <- reactive(mtcars[mtcars$cyl == input$cyl, ])
observe({
mcp_model_context(
text = sprintf("The user is looking at the %d cars with %s cylinders.", nrow(cars()), input$cyl),
data = list(cyl = input$cyl, cars = rownames(cars()))
)
})
}Each call replaces the last. Keep it short and factual: the model
reads it along with everything else in the conversation.
mcp_send_message() goes further and posts a message into
the chat as the user.
Both do nothing in an ordinary Shiny session, so the same app still
runs with shiny::runApp(); is_mcp_session()
tells the two apart.
The chat around the app
mcp_host_context() returns what the chat client said
about itself: its color theme, whether the app is shown inline or full
screen, and the user’s locale and time zone. It is reactive, so a plot
that reads the theme redraws when the user switches the chat to dark
mode.
mcp_request() describes the call being handled. On Posit
Connect it includes the signed-in user and their groups, which the
server function can use to decide what data to show.
What works on the page
The page is the app’s UI, rendered once to HTML, with shinymcp’s
JavaScript in place of Shiny’s. It draws Shiny’s inputs itself, and
shows every kind of output: text, tables, plots and images,
renderUI(), and htmlwidgets such as plotly, DT, reactable,
and leaflet. Notifications, modal dialogs, insertUI(), tab
changes (updateTabsetPanel(), insertTab(),
hideTab()), downloads, and file uploads work.
invalidateLater() and reactivePoll() keep
running while the app is open, and an ExtendedTask’s result
appears when the task finishes. Clicks, double clicks, hovers, and
brushes on a plot (plotOutput(click =, brush =)) send what
they send in a browser, so nearPoints() and
brushedPoints() work.
Select inputs are the browser’s own. One whose choices stay on the
server (updateSelectizeInput(server = TRUE)) shows the
first 1,000 of them, with a search box for the rest.
conditionalPanel() works too, with one difference. Shiny
runs a panel’s condition as JavaScript, which chat clients forbid, so
shinymcp reads it instead. It understands what conditions are usually
made of: comparisons, &&, ||,
!, arithmetic, input.x and
output.x (including an output set to a
reactive()), .length, regular expressions, and
common string and array methods such as indexOf() and
includes(). A condition it can’t read shows its panel, with
a warning in the browser’s console.
Packages that talk to Shiny’s JavaScript work too. The page provides
window.Shiny with what they use: input bindings
(shinyWidgets’ pickers, switches, and button groups; bslib’s sidebars,
accordions, and task buttons), Shiny.setInputValue() (row
selection in DT, plotly::event_data(), clicks on a leaflet
map), custom message handlers (shinyjs), and the events that loading
spinners listen for (shinycssloaders).
The app starts the way shiny::runApp() starts it:
global.R, the files in R/, and
onStart run once, before the UI is built, and the app’s
code runs in its own directory. Scripts, stylesheets, and images the UI
loads from www/, or from a path added with
addResourcePath(), are written into the page.
Some things don’t carry over:
-
Content from other sites. Chat clients show the
page under a strict Content Security Policy that blocks anything from
another domain unless the app declares it. Map tiles, web fonts, and
scripts from a CDN need a
cspdeclaration, for exampleas_mcp_app(app, csp = list(resource_domains = "https://*.tile.openstreetmap.org")). (Leaflet’s default markers don’t: shinymcp puts their images in the page.) Fonts can’t load from the app itself either, so icon fonts such as Font Awesome don’t show; use inline SVG icons (bsicons, for example) instead. - Output bindings from packages, other than htmlwidgets. Their values are shown as text.
-
Progress bars. A request finishes before the page
hears about its progress, so
withProgress()shows nothing. -
The page’s URL. Bookmarking,
updateQueryString(), andsession$reload()have nothing to act on.
Sessions in a server
Sessions live in the R process that serves the app: up to 50 at a
time, each closing after an hour without use. The
shinymcp.max_views and shinymcp.view_timeout
options change those limits; help("shinymcp-options") lists
the others.
If a request reaches a process that doesn’t have its view’s session
(after a restart, or behind a load balancer that spreads requests over
several R processes), the page’s full input state starts a new session,
and the view carries on. What the server function kept outside its
inputs, such as a reactiveVal() counting clicks, starts
over. vignette("deployment") covers serving apps over HTTP
and on Posit Connect.
