Skip to contents

shinymcp implements the MCP Apps specification (version 2026-01-26), the optional Model Context Protocol extension that lets MCP servers ship interactive HTML UIs that render inside AI chat clients. This article explains how the protocol works, exactly which parts shinymcp implements, and where shinymcp deliberately deviates. Read this when you want to debug a misbehaving app, integrate with a new host, or contribute to shinymcp itself.

The three parties

Every MCP App involves three processes:

  1. Server: your R process, started by serve(app). It answers MCP JSON-RPC requests over stdio or HTTP: tools/list, tools/call, resources/list, resources/read.
  2. Host: the chat client (Claude Desktop, ChatGPT, VS Code, or shinymcp’s own Shiny host and preview_app()). It talks MCP to the server and renders your app’s HTML inside a sandboxed iframe.
  3. View: your app’s HTML page running inside that iframe. The shinymcp JS bridge (inlined into the page) talks to the host with postMessage/JSON-RPC. It never talks to the server directly; the host proxies everything.

Lifecycle walkthrough

A typical session looks like this:

Host  -> Server   initialize (advertises extensions["io.modelcontextprotocol/ui"])
Host  <- Server   capabilities + negotiated protocolVersion
Host  -> Server   tools/list
Host  <- Server   tools with _meta.ui.resourceUri = "ui://your-app"
Model            calls a tool; host sees _meta.ui.resourceUri
Host  -> Server   resources/read "ui://your-app"
Host  <- Server   HTML (text/html;profile=mcp-app) + _meta.ui (CSP, border)
Host             renders HTML in a sandboxed iframe

View  -> Host     ui/initialize (appInfo, appCapabilities)
View  <- Host     hostInfo + hostContext (theme, locale, displayMode, styles)
View  -> Host     ui/notifications/initialized
Host  -> View     ui/notifications/tool-input (the model's arguments)
Host  -> View     ui/notifications/tool-result (when the call completes)

User changes an input in the iframe:
View  -> Host     ui/update-model-context (current input values)
View  -> Host     tools/call (debounced)
Host  -> Server   tools/call (proxied)
View  <- Host     result -> bridge updates output elements

Host  -> View     ui/resource-teardown (before removing the iframe)
View  <- Host     response, then cleanup

Two messages in that flow matter most:

  • ui/update-model-context pushes the UI’s current state into the model’s context. This is why the model “knows” what the user selected in the rendered app and can act on it in the next turn.
  • ui/notifications/tool-input flows the other way: when the model calls a tool, the host pushes those arguments into the UI so the rendered controls match what the model asked for.

Compliance summary

Server (MCP over stdio/HTTP)

Spec feature Status
initialize version negotiation (2024-11-05 … 2025-11-25)
Extension capability check (io.modelcontextprotocol/ui) ✅ graceful degradation (see below)
tools/list, tools/call
resources/list, resources/read
ping
Tool _meta.ui.resourceUri ✅ (plus deprecated flat ui/resourceUri alias)
Tool _meta.ui.visibility ("model" / "app" scoping) ✅ via mcp_app(tool_visibility = )
Resource _meta.ui.csp / permissions / prefersBorder ✅ via mcp_app(csp = , permissions = , prefers_border = )
tools outputSchema ✅ generated via mcp_app(tool_outputs = ), or pass-through for plain-list tools
Extra (non-UI) resources ✅ via mcp_app(resources = )
Streamable HTTP session management (Mcp-Session-Id) ✅ assigned on initialize, DELETE terminates
List pagination (cursor) ❌ not needed for single-app servers
resources/subscribe, listChanged notifications

View (JS bridge, MCP Apps 2026-01-26)

Spec feature Status
ui/initialize handshake + ui/notifications/initialized
ui/notifications/tool-input / tool-result / tool-cancelled / tool-input-partial ✅ (partial input accepted, not yet rendered progressively)
ui/update-model-context (request semantics)
ui/notifications/size-changed via ResizeObserver
ui/resource-teardown (responds before cleanup)
ping
JSON-RPC error responses surfaced to callers
Host context: themedata-bs-theme, locale, styles.variables ✅ applied automatically
ui/open-link, ui/message, ui/request-display-mode, notifications/message ✅ exposed via window.shinymcp (below)
resources/read from the view window.shinymcp.readResource(uri)

Graceful degradation

MCP Apps is an optional extension that clients negotiate. Clients advertise support in initialize under capabilities.extensions["io.modelcontextprotocol/ui"]. When a client does not advertise it, shinymcp still serves all tools and withholds the nested _meta.ui block. The deprecated flat _meta["ui/resourceUri"] key is kept in both cases, so hosts that predate capability negotiation (SEP-1865 draft era) keep finding the UI resource, while text-only clients ignore the unknown key per the MCP spec. You don’t need to do anything to get this behavior.

Theming: matching the host’s light/dark mode

The host tells the view its theme in hostContext (and again via ui/notifications/host-context-changed when it changes). The shinymcp bridge applies it automatically by setting data-bs-theme="dark" (or "light") on the document element:

  • bslib apps (Bootstrap 5.3+) restyle automatically.
  • Plain shinymcp components get a built-in dark variant.
  • Custom CSS can opt in with selectors like :root[data-bs-theme="dark"] .my-widget { ... }.

Host-provided styles.variables (CSS custom properties) are also applied to :root, so hosts that publish design tokens flow through.

CSP: external assets and how to declare them

This is the most common way a working app breaks in a real host. Hosts apply a restrictive Content Security Policy to the iframe, by default roughly default-src 'none'; script-src 'self' 'unsafe-inline'; connect-src 'none'. That means:

  • No fetch()/XHR/WebSocket to any external service.
  • No scripts, stylesheets, fonts, or images loaded from CDNs.
  • Failures are silent: assets don’t load and nothing is reported.

shinymcp’s default posture avoids all of this: html_resource() inlines every dependency (bslib CSS/JS, your styles) into the HTML, and plots travel as base64 data URIs. If your app stays inside that model, you never think about CSP.

When you genuinely need external access, say an htmlwidget loading from a CDN, or client-side calls to an API, declare the domains so spec-compliant hosts can allow them:

app <- mcp_app(
  ui,
  tools,
  name = "my-app",
  csp = list(
    connect_domains = c("https://api.example.com"),
    resource_domains = c("https://cdn.jsdelivr.net")
  )
)

These are published as _meta.ui.csp on the ui:// resource. Hosts MUST NOT allow domains you didn’t declare, so be complete. There are also frame_domains (nested iframes) and base_uri_domains.

Related declarations:

  • permissions = list(camera = list()) for iframe permissions.
  • prefers_border = TRUE to hint the host should draw a frame.

Lazy-loading data with extra resources

Inlining keeps apps CSP-proof, but it also means every byte ships in the initial HTML resource. For large datasets, declare them as extra resources and fetch them on demand from the iframe. The request flows through the host to your R process, so no CSP declaration is needed:

app <- mcp_app(
  ui,
  tools,
  name = "my-app",
  resources = list(
    "ui://my-app/data" = list(
      content = function() jsonlite::toJSON(big_dataset),
      mime_type = "application/json",
      description = "Full dataset for client-side filtering"
    )
  )
)

Function content is evaluated on every read (so it can reflect current state); plain strings are served as-is. In the app’s JS:

window.shinymcp.readResource("ui://my-app/data").then(function (result) {
  var rows = JSON.parse(result.contents[0].text);
  // render client-side
});

This works in real chat hosts (which proxy resources/read natively), in shinymcp’s Shiny host, and in preview_app().

Tool output schemas

Tools that return a named list keyed by output ids can publish an outputSchema so hosts and models know the result shape. Declare the mapping and shinymcp generates the schema, deriving property descriptions from the matching UI output types (plot outputs are documented as base64 PNGs, tables as HTML markup, and so on):

app <- mcp_app(
  ui,
  tools,
  name = "my-app",
  tool_outputs = list(explore = c("scatter", "stats"))
)

Only declare tools whose results really match: per the MCP spec, a tool with an outputSchema MUST return conforming structuredContent, so shinymcp never guesses; undeclared tools omit the schema.

Tool visibility: app-only and model-only tools

By default every tool is visible to both the model and the rendered UI. The spec’s _meta.ui.visibility lets you scope that, and it maps naturally onto shinymcp’s design where fine-grained recompute tools can clutter the model’s tool list:

app <- mcp_app(
  ui,
  tools,
  name = "dashboard",
  tool_visibility = list(
    redraw_plot = "app",     # UI plumbing; hidden from the model
    export_report = "model"  # model-driven; UI must not call it
  )
)

The window.shinymcp API

Inside your app’s HTML you can script richer host interactions. The bridge exposes a small global:

// Call a server tool and handle the result yourself
window.shinymcp.callTool("get_data", { region: "EMEA" }).then(...)

// Read a server resource (see "Lazy-loading data" below)
window.shinymcp.readResource("ui://my-app/data").then(function (result) {
  var data = JSON.parse(result.contents[0].text);
});

// Push state into the model's context for future turns
window.shinymcp.updateModelContext({ selected_rows: [1, 5, 8] });

// Ask the host to open a link, send a chat message, or go fullscreen
window.shinymcp.openLink("https://example.com/docs");
window.shinymcp.sendMessage("Please summarize the selected rows.");
window.shinymcp.requestDisplayMode("fullscreen");

// Logging and host info
window.shinymcp.log("info", { event: "filter-applied" });
window.shinymcp.getHostContext(); // { theme, locale, displayMode, ... }

All of these are spec methods (tools/call, ui/update-model-context, ui/open-link, ui/message, ui/request-display-mode, notifications/message); hosts that don’t implement one return an error, which arrives as a rejected Promise.

Known deviations from the spec

shinymcp aims for strict compliance, with these documented exceptions:

  1. Private host extensions. shinymcp’s bundled Shiny host sends two non-spec notifications to drive its Apply/Reset toolbar: ui/notifications/trigger-tool-call and ui/notifications/reset. The bridge also accepts x-shinymcp/trigger-tool-call and x-shinymcp/reset spellings; a future release will migrate the host to the x-shinymcp/ namespace to vacate the spec-reserved ui/notifications/* prefix. Standard hosts never send these, so interop is unaffected.
  2. Legacy _meta["ui/resourceUri"]. Tools carry both the current nested key and the deprecated flat key for compatibility with hosts that haven’t updated. The flat key will be dropped once it’s removed from the spec.
  3. Shiny host sandboxing. mcp_embed() / mcp_host_server() render the app via srcdoc with sandbox="allow-scripts allow-same-origin" on the same origin as your Shiny app. This is appropriate for embedding your own trusted apps; it is not a hardened boundary for third-party HTML. The spec’s sandbox-proxy pattern (separate origin) is out of scope for the built-in host.
  4. initialArguments in hostContext. shinymcp’s own hosts pass initial tool arguments in hostContext.initialArguments alongside the spec’s ui/notifications/tool-input, which the bridge also handles. Standard hosts only use the latter; both work.

Debugging

preview_app(app) runs a local reference host with a protocol log panel (bottom-right toggle) showing every JSON-RPC message in both directions. It is usually the fastest way to see what a real host sees. For failure modes and fixes, see vignette("debugging-shinymcp").