Skip to contents

An MCP App is served by an MCP server. A chat client connects to it in one of two ways: it starts the server itself and talks to it over standard input and output (stdio), or it connects to a URL (Streamable HTTP). shinymcp serves apps both ways, and inside a Shiny app for Posit Connect.

Every way below works with an McpApp, a list of apps to serve from one server, or a path to a file or directory whose app.R makes one.

While you work: preview_app()

preview_app() serves the app over HTTP on your machine and opens a page that plays the part of a chat client: it calls the app’s tool the way a model would, shows the app in a sandboxed frame under the same Content Security Policy clients use, and lists what the model received, each tool call, and the protocol messages. Change the arguments and call again, or switch the theme and width to see the app in a dark or narrow chat.

preview_app() returns right away. Call $stop() on its result when you’re done.

Desktop clients, over stdio

Claude Desktop, Claude Code, VS Code, Goose, and most other desktop clients start an MCP server as a program. Write the app in a script that ends with serve():

# ~/apps/mileage/app.R
library(shiny)
library(shinymcp)

show_mileage <- ellmer::tool(...)

app <- mcp_app(fluidPage(...), tools = list(show_mileage), name = "mileage")
serve(app)

Run it in a terminal first: Rscript ~/apps/mileage/app.R should sit waiting for input (Ctrl+C stops it). An error there is the error the client would hit, without the client’s log in the way.

The protocol runs over standard output, so the script must not print to it before serve() starts; messages and warnings go to standard error, which is fine. While a request is being handled, anything the app prints is sent to standard error, where clients keep it in their logs.

Then register the script with the client, using full paths. On macOS, desktop apps don’t see your shell’s PATH, so give the full path to Rscript too (which Rscript prints it).

Claude Desktop, under Settings > Developer > Edit Config:

{
  "mcpServers": {
    "mileage": {
      "command": "/usr/local/bin/Rscript",
      "args": ["/Users/you/apps/mileage/app.R"]
    }
  }
}

Claude Code:

claude mcp add mileage -- Rscript /Users/you/apps/mileage/app.R

VS Code, in .vscode/mcp.json:

{
  "servers": {
    "mileage": {
      "type": "stdio",
      "command": "Rscript",
      "args": ["/Users/you/apps/mileage/app.R"]
    }
  }
}

Clients that can’t show apps (Claude Code, which runs in a terminal, is one) still get every tool the model can call, and their results as text.

Over HTTP

serve(app, type = "http", port = 8080)

This serves the app at http://127.0.0.1:8080/mcp, reachable from this machine only. To accept connections from elsewhere, listen on all interfaces with host = "0.0.0.0" and put the server behind a proxy that handles TLS and authentication; shinymcp does neither.

A server listening on this machine refuses requests for host names other than localhost and IP addresses. That protects it from web pages that point their own domain at your machine (DNS rebinding). If you reach it through another name, such as a proxy on the same machine, list the name in allowed_hosts. Browser pages from origins other than the server’s own need allowed_origins.

The server speaks both generations of the MCP protocol: the versions from 2024-11-05 to 2025-11-25, which start with a handshake and keep a session, and the stateless 2026-07-28 revision, in which every request stands alone and any server process can answer it.

Posit Connect

mcp_endpoint() turns an app into a Shiny app that answers MCP requests at /mcp. It deploys like any Shiny app, to Posit Connect, Shiny Server, or shinyapps.io:

# app.R
library(shiny)
library(shinymcp)

show_mileage <- ellmer::tool(...)

app <- mcp_app(fluidPage(...), tools = list(show_mileage), name = "mileage")
mcp_endpoint(app)

Chat clients connect to the content’s URL followed by mcp, for example https://connect.example.com/content/1a2b3c4d/mcp. People who open the content’s URL itself get the preview page. Try it locally with shiny::runApp(): the preview is at / and the endpoint at /mcp.

To deploy a Shiny app for people and an MCP App for chat clients together, give mcp_endpoint() both. People who open the URL get the Shiny app, and chat clients get the MCP App at /mcp. The two can share their UI and functions, the MCP App running a tool where the Shiny app runs a server function:

shinyApp(ui, server) |> mcp_endpoint(apps = app)

Given a Shiny app alone, mcp_endpoint() serves the same app, live, to chat clients through as_mcp_app():

shinyApp(ui, server) |>
  mcp_endpoint(name = "mileage", description = "Fuel economy by number of cylinders.")

That form goes when Shiny’s own MCP support is released and shinymcp stops serving live apps; see vignette("shiny-apps").

Signing in

Content that requires a login needs credentials on every request. Clients send a Connect API key as Authorization: Key <api-key>:

claude mcp add --transport http mileage \
  https://connect.example.com/content/1a2b3c4d/mcp \
  --header "Authorization: Key $CONNECT_API_KEY"

Connect checks the key and tells the app who the user is. Tool functions see them as mcp_request()$user and mcp_request()$groups (and a live Shiny app’s server function as session$user and session$groups), so an app can show each person only their data.

Processes

Apps built from tools keep no state between calls, so Connect can run them in as many processes as you like.

A live Shiny app is different: each open view keeps its session in the R process that started it. If Connect runs the app in several processes, a request can reach one that doesn’t have the session. shinymcp then starts a new session from the inputs on the page and the app keeps working, but anything the server function kept outside its inputs starts over. For apps that keep such state, set Max processes to 1 in the content’s runtime settings.

The posit-connect example has a complete app and the client settings for Claude Code and VS Code.