Skip to content

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

Draft
schloerke wants to merge 5 commits into
mainfrom
mcp
Draft

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

Conversation

@schloerke

@schloerke schloerke commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Integration branch mcpmain for the experimental MCP Apps support. The feature was built and reviewed across several PRs squash-merged into mcp (#4407 initial support, #4412 registerMcpTool(), #4414 mcpConfigure(), #4418 in-place update_<appId>_app); this PR promotes the accumulated work to main.

Summary

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

How it works, in brief:

  • Configuration. mcpConfigure(appId=, description=, arguments=, direct=, displayModes=, origin=, stdio=, enabled=) is the single entry point — an app is served over MCP only when it calls it. (Replaces the original options(shiny.mcp.*) interface.)
  • Transport. JSON-RPC over streamable HTTP on the app's own port, plus an optional stdio transport (mcpConfigure(stdio = TRUE)), both through one dispatcher.
  • Reactivity in the sandbox. The ui://shiny/<appId> 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.
  • Opening & steering the app. The app-opening tool is open_<appId>_app, with an input schema declared via mcpConfigure(arguments = list(...)) (ellmer types). Arguments reach the app through the reactive mcpUpdates() channel (a single observer handles both the initial open and later updates). When arguments are declared, a companion update_<appId>_app tool is auto-registered so the model can update an already-running instance in place (targeting a specific session by the token the app announces on connect) instead of re-opening it (MCP Apps: tool to update an already-running app in place (auto-registered update_<appId>_app) #4415/feat(mcp): update a running Shiny MCP App in place via update_<appId>_app (#4415) #4418).
  • Author-callable tools. registerMcpTool(ellmer::tool(...)) exposes plain R functions as model-callable MCP tools that run server-side, alongside the app-opening tool.
  • Session API (exported, experimental). isMcpSession(), mcpUpdates(), mcpHostContext(), mcpUpdateModelContext(), mcpSendMessage(), mcpRequestDisplayMode().
  • 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. mcpConfigure(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 (excluded 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.
  • In-place update (update_<appId>_app) verified end-to-end in Claude Desktop, including the multi-instance case (updating a specific open instance by its announced session token).

Known open item

Experimental (lifecycle::badge("experimental")); an app is never served over MCP unless it calls mcpConfigure().

schloerke and others added 5 commits July 14, 2026 15:25
* 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>
…() (#4412)

Replace options(shiny.mcp.tools=) with registerMcpTool(), accepting ellmer::tool() objects; convert to MCP tools/list schema via the mcptools as_json() path and invoke on tools/call with rlang::exec(). ellmer -> Suggests. ToolDef props read via S7::prop() for R < 4.3. Docs, demos, and mcp/ architecture updated.
* docs(mcp): design spec for mcpConfigure() replacing shiny.mcp.* options

* docs(mcp): implementation plan for mcpConfigure()

* feat(mcp): add mcpConfigure() with validation and .globals$mcp storage

* refactor(mcp): read config from .globals$mcp instead of options()

* refactor(mcp): rename mcpToolInput() to mcpUpdates()

* test(mcp): drive config via mcpConfigure() instead of options()

* docs(mcp): document mcpConfigure()/mcpUpdates(), update demos and NEWS

* docs(mcp): update stdio Windows warning to reference mcpConfigure()

* docs(mcp): spike notes for init-args-via-RestoreContext

* docs(mcp): correct Unknown-3 verdict in spike notes

* feat(mcp): restore init arguments via an active RestoreContext

* feat(mcp): bridge encodes init args into restore, updates via mcpUpdates()

* feat(mcp): filter model arguments to the declared allow-list

* fix(mcp): inject init restore args into the init frame so RestoreContext activates

* docs(mcp): rename demo 'ti' var to 'args' (post mcpUpdates rename)

* feat(mcp): deliver model args via single mcpUpdates() channel; drop RestoreContext init path

Real-host testing (Claude Desktop) showed init-arg widget auto-restore is
unreachable (UI renders at resources/read before args exist), hosts re-render
per tool call, and the restore-vs-mcpUpdates split raced. Route every
ontoolinput to mcpUpdates(); remove createRestoreObservers, the server.R MCP
restore branch, the JS init-frame injection + socket-start gate, encodeMcpRestore,
and mcpFilterRestore. Document the constraints in mcp/limitations.md.

* docs(mcp): fold config-API change into the umbrella NEWS entry (unreleased)

* docs(mcp): add clock demo (demo-app3) + stdio/TCC-path notes for Claude Desktop

* docs(mcp): list mcpConfigure() in pkgdown reference (fix routine CI check)
The @param arguments roxygen still claimed init args could be read 'via
input/bookmark restoration (init)'. That auto-restore path was proven
unworkable for MCP (the UI renders at resources/read before args exist)
and removed in #4414. Describe the real mechanism instead: a single
mcpUpdates() observer applying updateXxxInput(), with the unavoidable
open flash noted. Handles the leftover doc correction deferred as a
non-goal from #4415's design spec.

Regenerated man/mcpConfigure.Rd.
…_app (#4415) (#4418)

* docs(mcp): spec for in-place update of running Shiny MCP App (#4415)

* docs(mcp): implementation plan for in-place update of running MCP App (#4415)

* feat(mcp): publish update_<appId>_app tool when arguments declared (#4415)

* feat(mcp): mcpUpdates() overlays server-pushed args over init args (#4415)

* feat(mcp): dispatch update_<appId>_app to push args into a live session (#4415)

* feat(mcp): stamp session token into mcpUpdateModelContext payload (#4415)

* feat(mcp): announce session token to the model on connect (#4415)

* docs(mcp): document update_<appId>_app + update demos (#4415)

* fix(mcp): accumulate successive update pushes; review cleanups (#4415)

* docs(mcp): mark host-passthrough + multi-instance confirmed in Claude Desktop (#4415)
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