A Shiny app can show MCP Apps the way a chat client does: in a conversation built with shinychat, where a model opens them, or in a pane anywhere in the app. shinymcp does the chat client’s part. It connects to the app’s server, shows the app’s page in a sandboxed frame, and passes the page’s requests to the server.
The app can come from anywhere: built with shinymcp in the same R process, deployed to Posit Connect, a Shiny app served with Shiny’s own MCP support, or an MCP server written in another language.
The examples below host this app:
library(shiny)
library(shinymcp)
show_cars <- ellmer::tool(
function(cyl = "4") {
cars <- mtcars[mtcars$cyl == as.numeric(cyl), ]
list(
scatter = mcp_result_plot(function() plot(cars$wt, cars$mpg)),
count = paste(nrow(cars), "cars")
)
},
name = "show_cars",
description = "Plot weight against fuel economy for the cars with a given number of cylinders.",
arguments = list(
cyl = ellmer::type_enum(c("4", "6", "8"), "Number of cylinders.", required = FALSE)
),
annotations = ellmer::tool_annotations(read_only_hint = TRUE)
)
cars_app <- mcp_app(
fluidPage(
selectInput("cyl", "Cylinders", c(4, 6, 8)),
plotOutput("scatter"),
textOutput("count")
),
tools = list(show_cars),
name = "cars",
title = "Cars by cylinders"
)In a chat
mcp_chat_host() gives the model in a shinychat
conversation the apps’ tools. When it calls one that shows an app, the
app appears in the conversation, live:
library(shinychat)
ui <- bslib::page_fillable(chat_ui("chat"))
server <- function(input, output, session) {
chat <- chat_server("chat", ellmer::chat("anthropic/claude-sonnet-5"))
mcp_chat_host(chat, cars_app)
}
shinyApp(ui, server)The model gets each tool’s structured result, or its text when there is none, as it would from any MCP client. Then the person uses the app, and the host keeps the model in step with it:
- What the person does reaches the model. An app tells its host what the model should know about it; an app built with shinymcp reports the values the person has set in it. Before each message the person sends, the model is told what every open app reports. It sees the apps as they are now, not as they were a few messages ago, and the saved conversation keeps only what the person and the model said.
-
Apps can suggest a message. When an app asks to
post one (a button such as “Ask about this”), it goes into the chat’s
input box for the person to read and send.
messages = "submit"sends it at once, for apps you trust. - Saved conversations come back live. When shinychat restores a conversation, its cards show their apps again, on the result the model got, without calling the tools again.
What an app reports comes from the app, so the model is told that, and each report is cut to 2,000 characters.
The report reaches the model as a message of its own, just before the
person’s. A few providers, such as AWS Bedrock, refuse two user messages
in a row. For those, pass context = FALSE and add
host$context() to the person’s message yourself:
host <- mcp_chat_host(chat, cars_app, context = FALSE)
host$context() # What the open apps report, or NULLOther arguments set how the cards look: title and
icon, open for whether a card starts expanded,
show_request for whether it shows the call’s arguments, and
full_screen. value_fn changes what the model
gets.
Cards alone
as_shinychat_tool() is the part of
mcp_chat_host() that makes the tools. Register its tools
yourself when you want the cards without the rest:
client <- ellmer::chat("anthropic/claude-sonnet-5")
client$register_tool(as_shinychat_tool(cars_app))
chat_server("chat", client)For a remote server, make the tools where the app starts, outside the
server function, and register them in each session. Listing a server’s
tools waits for it, so in a session as_shinychat_tool()
uses only the list the client already has:
sales_tools <- as_shinychat_tool(mcp_client("https://connect.example.com/sales/mcp"))
server <- function(input, output, session) {
client <- ellmer::chat("anthropic/claude-sonnet-5")
client$register_tools(sales_tools)
chat_server("chat", client)
}To put an app in the conversation without a tool call, for example a
form when the chat starts, build the card with
mcp_content_result() and append it with
chat_append(). In a Shiny session
mcp_content_result() calls the app’s tool and returns a
promise, which chat_append() waits for, so the card is
saved with what the tool returned and a restored conversation shows the
app as it was.
In a pane
mcp_host_ui() and mcp_host_server() host an
app anywhere in a Shiny app, with no model. The host calls the app’s
tool when the pane opens, with the arguments you give, as a
model would:
ui <- bslib::page_sidebar(
sidebar = bslib::sidebar(verbatimTextOutput("context")),
mcp_host_ui("cars")
)
server <- function(input, output, session) {
host <- mcp_host_server("cars", cars_app, arguments = list(cyl = "6"))
output$context <- renderPrint(host$model_context())
}mcp_host_server() returns what happens in the app as
reactives: the context it would give a model
(model_context()), its latest tool call and result
(last_tool_call(), last_result()), and the
messages it asked to post (messages()). open()
opens the app again with other arguments:
observeEvent(input$cylinders, host$open(list(cyl = input$cylinders)))That makes panes useful for reusing an app built for chat clients as part of a dashboard, for Shiny apps that react to what someone does in one, and for seeing exactly what a model would be told before you give an app to one.
For UI created on the server, such as inside renderUI(),
mcp_embed() does both halves at once.
Apps from other servers
mcp_client() connects to an MCP server over HTTP.
Anywhere the examples above take an app, they also take a client:
server <- function(input, output, session) {
sales <- mcp_client(
"https://connect.example.com/sales/mcp",
headers = list(Authorization = paste("Key", Sys.getenv("CONNECT_API_KEY")))
)
chat <- chat_server("chat", ellmer::chat("anthropic/claude-sonnet-5"))
mcp_chat_host(chat, list(cars_app, sales))
}The connection and any credentials stay in R: the app’s page never
talks to its server itself, and the browser never sees the key.
headers can also be a function, called before each request;
create the client in the server function, as here, for each visitor’s
requests to carry their own credentials.
Each source needs a name of its own, since saved conversations refer
to their apps’ servers by name. A client is named after its URL; set
name to keep it stable if the URL changes.
A Shiny app served with Shiny’s own MCP support is a server like any
other: point mcp_client() at its /mcp path.
Shiny apps can hold a request open while they wait for the app to
change, so give the client a timeout longer than that
wait.
The client also works on its own, to call a deployed server’s tools from R:
sales$tools()
sales$call_tool("open_sales_app", list(region = "West"))Checking the calls apps make
An app’s page calls its tools as the person uses it: to fill its
outputs, or when they press a button such as “Save” or “Cancel order”.
mcp_chat_host(), mcp_host_server(), and the
other hosts take on_app_call, a function that sees each of
these calls before it’s sent. Use it to keep a record of who did what,
or to refuse what shouldn’t be done from here:
app_call_policy <- function(call) {
message(
format(Sys.time()), " ", call$session$user,
" called ", call$name, " in ", call$title
)
if (call$name == "cancel_order") {
return("Cancel orders in the orders app.")
}
TRUE
}
server <- function(input, output, session) {
sales <- mcp_client("https://connect.example.com/sales/mcp")
chat <- chat_server("chat", ellmer::chat("anthropic/claude-sonnet-5"))
mcp_chat_host(chat, list(cars_app, sales), on_app_call = app_call_policy)
}The function gets a list describing the call: the tool’s
name and its arguments, its definition
(tool, with annotations such as
destructiveHint), the app’s title, and the
Shiny session, whose user is the signed-in
user on Posit Connect. TRUE lets the call through.
FALSE or a string refuses it, and the app is given the
string as the reason. To ask someone first, return a promise that
resolves to one of these once they answer; the session carries on
meanwhile. An error, or any other answer, refuses the call.
A page can call a tool each time an input changes, so ask a person
only about the tools that need it. on_app_call sees only
what the app’s page does: the model’s own calls are checked with
ellmer’s on_tool_request() callback.
What an app can do
An app’s page runs in a sandboxed frame. It can’t reach the Shiny
page, its cookies, or the Shiny session, and it can load only what the
app declared (its csp); with nothing declared, it has no
network access. It can call its own server’s tools that are visible to
apps (and that on_app_call lets through), read its
resources, ask to open links (web and email only), download files, and
ask for full screen. Everything else it asks for is refused.
The model can call only the tools of the sources you give it.
The shinychat,
shiny-host,
and remote-host
examples are complete apps.
