An app made with mcp_app() has two halves: tools, which
are R functions the model can call on its own, and a page of inputs and
outputs that calls the same functions when the person using it changes
something. Nothing runs between calls. Every answer depends only on its
arguments, so any R process can give it, and the tools are as useful in
chat clients that can’t show the page, or to other agents, as in ones
that can.
To start from a Shiny app you already have, see
vignette("rewriting-as-tools").
A first app
library(shinymcp)
summarize_dataset <- ellmer::tool(
function(dataset = "mtcars") {
data <- getExportedValue("datasets", dataset)
list(
rows = nrow(data),
summary = paste(capture.output(summary(data)), collapse = "\n")
)
},
name = "summarize_dataset",
description = "Summarize one of R's built-in datasets, column by column.",
arguments = list(
dataset = ellmer::type_enum(c("mtcars", "faithful"), "The dataset to summarize.")
)
)
app <- mcp_app(
ui = htmltools::tagList(
mcp_select("dataset", "Dataset", c("mtcars", "faithful")),
mcp_text("rows"),
mcp_text("summary")
),
tools = list(summarize_dataset),
name = "datasets"
)Names connect the two halves. The tool’s argument
dataset takes its value from the input with id
dataset, and the list the tool returns fills the outputs
with ids rows and summary. When the person
picks another dataset, the page calls every tool that takes
dataset and fills in what they return.
$call_tool() runs a tool and returns its R value, which
is what you want in tests:
str(app$call_tool("summarize_dataset", list(dataset = "faithful")))
#> List of 2
#> $ rows : int 272
#> $ summary: chr " eruptions waiting \n Min. :1.600 Min. :43.0 \n 1st Qu.:2.163 1st Qu.:58.0 \n Median :4.0"| __truncated__$run_tool() returns what an MCP client receives:
result <- app$run_tool("summarize_dataset", list(dataset = "faithful"))
cat(result$content[[1]]$text)
#> rows: 272
#>
#> summary:
#> eruptions waiting
#> Min. :1.600 Min. :43.0
#> 1st Qu.:2.163 1st Qu.:58.0
#> Median :4.000 Median :76.0
#> Mean :3.488 Mean :70.9
#> 3rd Qu.:4.454 3rd Qu.:82.0
#> Max. :5.100 Max. :96.0Try it with preview_app(app), and see
vignette("deployment") for putting it in a chat client.
Inputs
Any input that has the argument’s name as its id works: Shiny’s
(selectInput(), sliderInput(),
dateRangeInput(), …), bslib’s, and the small ones shinymcp
provides, mcp_select() and its relatives, which need
neither package. When an id can’t match the argument, because two tools
share the page or a module prefixed it, mark the element with
mcp_input(tag, id = "argument").
By default the page calls tools a quarter of a second after the
person stops changing inputs. mcp_app(trigger = "change")
calls on every change, and trigger = "submit" waits for an
apply button, which mcp_submit_button() places (otherwise
the page adds one at the bottom). Buttons made with
mcp_action_button() or shiny::actionButton()
call the tools that take their id when pressed. Those tools run only
then, as an eventReactive() would, and not when their other
inputs change.
Outputs and results
mcp_text(), mcp_plot(),
mcp_table(), and mcp_html() make the elements
results are drawn into. Shiny’s output functions work too, matched by id
the same way. A plot is drawn at the size of its output: the page tells
each tool call how big its plot outputs are, and calls again when one
changes size. A call the page didn’t make, such as the model’s, draws at
800 by 500 pixels, which the page shows scaled to fit until it has asked
for the right size. Give mcp_result_plot() a
width and height to draw at a size of your
own.
A tool can return plain values: a string, a number, a data frame, an
htmltools tag, a ggplot object. The mcp_result_*()
functions say more about a value and what the model should read for
it:
list(
summary = mcp_result_text(text, model_value = list(n = nrow(data))),
table = mcp_result_table(head(data, 20)),
plot = mcp_result_plot(function() hist(data$mpg), text = "Histogram of mpg"),
report = mcp_result_pdf("report.pdf")
)Each result is split three ways, because it has three readers:
-
Text in
content, for the model and for clients that can’t show the app. By default it joins each output’s text. -
Structured data in
structuredContent, for the model: one value per output, such as a table’s rows or themodel_valueyou gave. -
What the page draws (HTML, images, widget data and
their JavaScript) in
_meta, which clients pass to the page and keep from the model.
mcp_tool_result() writes the first two yourself, for
example to lead with the identifiers the model should quote back, or to
report an error the model should act on:
mcp_tool_result(
answer = sprintf("%d per group.", n),
curve = mcp_result_plot(draw_curve),
text = sprintf("The study needs %d participants per group.", n),
data = list(n_per_group = n, power = power)
)The sample-size
example is a complete app in this style.
Tools
ellmer::tool() is the most direct way to write a tool:
its argument types become the JSON Schema the model sees, and its
annotations (read_only_hint and the rest) tell clients what
the tool does. Plain lists work too, with name,
description, fun, and optionally
inputSchema, outputSchema, and
annotations.
The model’s arguments are checked against the schema before the
function runs: required arguments must be there, and values must have
the declared type and, for choices, one of the listed values. A problem
goes back to the model as a tool error it can correct. Arguments you
pass from R, to $call_tool() for instance, are read as a
client would send them: a vector of one value is one value, not an
array, so write an array of one as list(x).
Some tools shouldn’t be the model’s to call: saving a result, sending
an order, anything the person should decide.
tool_visibility hides a tool from the model while the page
can still call it. Give such a tool the id of a button as an argument,
and it runs when the person presses the button, with the other inputs it
takes as they are:
save_choice <- ellmer::tool(
function(save, dataset) {
saveRDS(dataset, "choice.rds")
list(status = paste("Saved", dataset))
},
name = "save_choice",
description = "Save the chosen dataset.",
arguments = list(
save = ellmer::type_integer("Times the Save button was pressed."),
dataset = ellmer::type_string("The dataset to save.")
),
annotations = ellmer::tool_annotations(read_only_hint = FALSE)
)
mcp_app(
ui = htmltools::tagList(
mcp_select("dataset", "Dataset", c("mtcars", "faithful")),
mcp_action_button("save", "Save"),
mcp_text("status")
),
tools = list(save_choice),
tool_visibility = list(save_choice = "app")
)The page never runs a tool annotated with
read_only_hint = FALSE or
destructive_hint = TRUE on its own, to fill in outputs when
it opens or because an input it takes changed. It runs when the person
presses a button it takes, so give such a tool one.
tool_outputs declares the outputs each tool returns
(list(explore = c("scatter", "stats"))), which gives it an
outputSchema.
Telling the model what the person did
After the person changes something, the page tells the model on its
next turn which values they chose. Set
mcp_app(model_context = FALSE) to turn that off. The page’s
JavaScript can also do it directly, with
window.shinymcp.updateModelContext(), and post a message
into the chat with window.shinymcp.sendMessage().
Look and feel
The UI can be a whole page, such as
bslib::page_sidebar(), or a fragment; give a fragment a
theme with mcp_app(theme = bslib::bs_theme()). By default
the page takes the chat client’s colors and fonts when it provides them,
and a page built on Bootstrap 5 (bslib) follows the client’s dark mode,
so the app matches the conversation around it.
host_styles = FALSE keeps your own theme.
Everything the page needs is written into it, because chat clients
show apps under a Content Security Policy that blocks other sources.
Files the UI loads by relative path come from www
(mcp_app(ui, tools, www = "www")). Anything from another
site needs a csp declaration, such as
csp = list(resource_domains = "https://cdn.example.com").
Loading data on demand
A page that needs more data than a result should carry (a large
table, a map layer) can read it when it needs it. Declare it with
resources, and read it from the page’s JavaScript with
window.shinymcp.readResource():
mcp_app(
ui,
tools,
resources = list(
"ui://datasets/stations.json" = function() jsonlite::toJSON(stations)
)
)The feature-tour
example uses this and the rest of the page’s JavaScript API.
