Skip to content

feat: serve Shiny apps as MCP Apps (SEP-1865)#4409

Draft
schloerke wants to merge 1 commit into
mainfrom
mcp
Draft

feat: serve Shiny apps as MCP Apps (SEP-1865)#4409
schloerke wants to merge 1 commit into
mainfrom
mcp

Conversation

@schloerke

Copy link
Copy Markdown
Collaborator

Integration branch mcpmain for the experimental MCP Apps support. The feature was developed and reviewed on #4407 (squash-merged into mcp); this PR promotes it to main.

Summary

Adds experimental support for serving a Shiny app as an MCP App (SEP-1865): with options(shiny.mcp = TRUE) an app mounts a Model Context Protocol endpoint at /mcp and renders live and reactive inside an MCP host's conversation (Claude Desktop, claude.ai, VS Code Copilot, etc.).

How it works, in brief:

  • Transport. JSON-RPC over streamable HTTP on the app's own port, plus an optional stdio transport (options(shiny.mcp.stdio = TRUE)), both through one dispatcher.
  • Reactivity in the sandbox. The ui://shiny/app resource is the rendered page as a single self-contained HTML document (assets inlined, CSS url()data: URIs). A JS bridge overrides Shiny.createSocket: it opens a real WebSocket where the host honors the declared CSP (direct fast path), otherwise it tunnels Shiny's websocket over tools/call (long-poll for server→client). HTTP side channels (upload/download, server-side selectize/DataTables, dynamically inserted htmlDependencies) tunnel through an app-only _shiny_http tool.
  • Session API (exported, experimental). isMcpSession(), mcpToolInput(), mcpHostContext(), mcpUpdateModelContext(), mcpSendMessage(), mcpRequestDisplayMode(). Author-declared model-callable tools via options(shiny.mcp.tools=).
  • Deployment. The direct-connect base URL is derived from platform headers, verified live on Posit Connect (RStudio-Connect-App-Base-Url) and shinyapps.io (X-Redx-Frontend-Name), falling back to deployment records / request origin.
  • Multiple apps behind one connector. options(shiny.mcp.appId=) namespaces each app's internal tools and resource URI; a small Node gateway (mcp/gateway/) merges several app endpoints into a single MCP server.

Design and working docs (E2E-ignored from the package build) live under mcp/ — see mcp/architecture.md for the full "system as built" write-up.

Verification

  • Full R CMD check matrix green (all Ubuntu 4.2→devel, macOS, Windows), plus routine and pkgdown.
  • E2E-verified in the ext-apps reference host (basic-host): reactivity, uploads, dynamic UI, server-side selectize, a checkbox-gated plotly widget (dynamic htmlwidget dependency), theme sync, direct vs. tunnel transport, and two apps behind one gateway.
  • Verified against real deployments on Posit Connect and shinyapps.io, and over stdio from claude/Claude Desktop.

Known open item

Experimental (lifecycle::badge("experimental")); not enabled unless options(shiny.mcp = TRUE).

* feat(mcp): JSON-RPC endpoint skeleton for MCP Apps

* feat(mcp): mount /mcp handler in app handler chain when enabled

* feat(mcp): postMessage tunnel connection and _shiny_* tools

* feat(mcp): ui://shiny/app single-file resource with inlined assets

* feat(mcp): postMessage bridge bundle overriding Shiny.createSocket

* fix(mcp): use .globals for sweeper flag (assignInMyNamespace fails in later callback)

* docs(mcp): NEWS entry for experimental MCP Apps support

* docs(mcp): mirror MCP Apps plans, research notes, and demo app into mcp/

* feat(mcp): _shiny_http tool tunnels HTTP side channels in-process

* feat(mcp): inline dynamic htmlDependencies for MCP tunnel sessions

* feat(mcp): tunnel XHR side channels, downloads, host theme + context (Phase 2 client)

* docs(mcp): Phase 2 NEWS + mirror plan/demo/screenshot into mcp/

* feat(mcp): inline CSS url() assets as data URIs for the sandbox

* feat(mcp): exported session API — mcpToolInput, mcpUpdateModelContext, mcpSendMessage, mcpHostContext, isMcpSession

* feat(mcp): author-declared MCP tools via options(shiny.mcp.tools)

* feat(mcp): stdio transport for local desktop hosts

* feat(mcp): direct-connect WebSocket fast path + display mode requests

* docs(mcp): direct-connect/display-mode NEWS, mcptools upstream notes, QUESTIONS.md

* docs(mcp): use real PR number in NEWS

* docs(mcp): record decisions in QUESTIONS.md; explain direct-connect default

* fix(mcp): declare processx/pkgload test deps; run stdio subprocess against installed shiny under R CMD check

* feat(mcp): path-aware direct-connect base (shiny.mcp.origin, X-RSC-Request, rsconnect records)

* feat(mcp): discover deployed URL from Quarto _publish.yml and Posit Publisher records

* fix: pkgdown index for mcp-session; wrap-tolerant reactlog Suggests test; drop Quarto _publish.yml source

* `npm run build` (GitHub Actions)

* fix(mcp): use RStudio-Connect-App-Base-Url for the direct-connect base

Verified against a real connect.posit.it deployment: Connect does not
send X-RSC-Request to Shiny content, and rsconnect excludes its own
rsconnect/ records from the bundle. The header Connect actually sends
with every proxied request is RStudio-Connect-App-Base-Url, which is
already the app's external base URL. Honor it first (X-RSC-Request kept
for API content).

* feat(mcp): shinyapps.io direct-connect base via X-Redx-Frontend-Name

Verified against a real shinyapps.io deployment: the proxy passes
POST /mcp through unauthenticated, sends RStudio-Connect-App-Base-Url
with an EMPTY value, and identifies the external base as
X-Redx-Frontend-Name (schemeless host + path). Combine that with
X-Forwarded-Proto for the direct base, and prefer X-Forwarded-Host
over the internal Host for the generic origin fallback and
deployment-record host matching.

* feat(mcp): appId namespacing so a gateway can serve multiple apps

options(shiny.mcp.appId=) prefixes the internal _shiny_* tunnel tools
(demo_shiny_connect, ...) and publishes the UI resource as
ui://shiny/<appId>; the bridge reads appId from __shinyMcpConfig__ and
prefixes its tool calls to match. mcp/gateway/shiny-mcp-gateway.mjs
(zero-dep Node, stdio or HTTP) merges several app endpoints into one MCP
server, so a single connector exposes many Shiny apps. E2E verified in
basic-host with two apps behind one gateway, including full tunnel
reactivity.

* docs(mcp): add mcp/architecture.md describing the system as built

* docs(mcp): compare with JamesHWade/shinymcp

* fix(mcp/gateway): cookie stickiness, cold-start wake retry, drain on close

shinyapps.io rejects POST to a sleeping app with 405 (only GET
cold-starts an instance) and pins clients to an instance via a session
cookie. The gateway now keeps a per-backend cookie jar, and on a failed
POST wakes the app with a GET to its base URL and retries with backoff.
Also drain in-flight requests before exiting when stdin closes.

* chore(mcp): commit demo-app deployment records (no secrets; exercise the DCF direct-base path)

* fix(mcp): inline render-time deps from direct createWebDependency() calls

{htmlwidgets} (and friends) call createWebDependency() directly for the
dependencies attached to a widget's render payload, bypassing the
processDeps() MCP hook — so a dynamically shown plotly widget arrived
with URL-based crosstalk/CSS deps that 404 inside the sandbox
("crosstalk is not defined"). createWebDependency() now diverts
file-based deps to mcpInlineDependency() when the current reactive
domain is an MCP session; href-only deps fall through to avoid
recursion. Demo: cars app gains a checkbox-gated plotly plot,
E2E-verified in basic-host (Plotly + crosstalk load on demand, 32
points rendered).

* test(mcp): write download bytes with writeBin for Windows

writeLines() opens the file path in text mode, so on Windows the CSV's
\n bytes became \r\n and the _shiny_http download body assertion failed
(only Windows R-CMD-check was red). writeBin(charToRaw(...)) writes the
exact bytes on every platform.

* chore: ignore .playwright-mcp scratch dir (git + R build)

---------

Co-authored-by: schloerke <schloerke@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant