Repository navigation
MCP Apps: tool to update an already-running app in place (auto-registered update_<appId>_app) #4415
Description
Summary
Add a way for an MCP host/model to update an already-running Shiny MCP app in place, rather than only re-opening it. Concretely: when an app declares arguments in mcpConfigure(), auto-register a companion update_<appId>_app tool that pushes new arguments into the running session's mcpUpdates() reactive channel — no re-render.
Follow-up to #4414 (the mcpConfigure() / init-args-via-RestoreContext work).
Motivation — what we observed with a real host
In a live Claude session driving a clock app (open_clock_app with label / paused args), asking Claude to "change the label" caused it to re-invoke open_clock_app, rendering a fresh widget each time (the user saw multiple app instances in the transcript). Claude's own explanation:
"I can't send additional arguments to an already-running instance. The only way to modify the app is by calling the tool again with new parameters."
Root cause: the model's only lever on the app is calling a tool, and the only tool it has is open_*. Whether a repeat open_* call becomes an in-place update or a new render is up to the host — and Claude renders a new instance per call. So the runtime-update path we built (mcpUpdates(), fed by a host ui/notifications/tool-input to the live iframe) is effectively never triggered by Claude. In practice the feature behaves as init-args-only.
Giving the model an explicit update verb (a separate tool) fixes this: the model can choose to update in place instead of re-opening, and the call reaches the running session.
Proposed design
The update tool
- When
mcpConfigure(arguments = ...)is set, auto-register a companion tool, e.g.update_<appId>_app, whoseinputSchemais the same declaredargumentsallow-list, with a description like: "Change parameters of the already-open app in place. Do NOT re-open the app; use this to update the running instance." - Unlike the app-opening tool (whose args are delivered to the iframe via
ontoolinput), a plain tool call runs in the R server process (like existingregisterMcpTool()tools). The handler locates the running app session(s) and pushes the (allow-list-filtered) args into the samemcpUpdates()channel the app already observes:observe({ args <- mcpUpdates() # now also fires from a server-side update push if (!is.null(args$label)) ... }) - This revives
mcpUpdates()as a real runtime path — fired by a server-side push instead of a host tool-input notification — and reusesargumentsend-to-end. Noresources/read, no new render.
Session targeting — the load-bearing decision
A tool call has no inherent affinity to a rendered iframe, so the server must decide which running session to push to. We'll need a registry of active MCP app sessions (populated on session start when isMcpSession() is true, covering both tunnel and direct-connect sessions; cleaned up on disconnect).
Targeting options, in rough order of preference:
- Explicit session handle (preferred).
open_*returns a session id the model passes intoupdate_*(session = "...").- Timing catch: at
tools/call open_*time the Shiny session does not exist yet (it's created when the iframe connects, afterresources/read), soopencannot return a realsession$token. Two ways around it:- (a)
openreturns a server-generated correlation id; propagate it into the eventual session so it registers under that id. Propagation needs the resource render to carry the id — see theresourceUri-args spike below (host-behavior dependent). - (b) The running app announces its token to the model on connect via
mcpUpdateModelContext(data = list(session = session$token)); the model then passes it toupdate_*. No spike dependency; relies on the model retaining/using it.
- (a)
- Timing catch: at
- Most-recently-opened session — "update the one you just opened." Simple; good default for the common single-instance-per-conversation case.
- Broadcast to all active sessions of the app — simplest; correct when there's one visible instance; ambiguous with multiple.
Recommend shipping most-recent/broadcast as the default and layering explicit handles (option 1) on top for multi-instance disambiguation.
Shape
- Built-in/auto-registered companion tool (preferred): zero author code; the model gets
open+updateas a pair. - Alternatively expose a helper (e.g.
mcpPushUpdate(session, args)) usable inside an author's ownregisterMcpTool()handler, for full control.
Related findings from #4414 to fold in
- Widget auto-restore doesn't work for MCP init. Because the resource UI is rendered at
resources/readwith no restore context (fake request, args unknown at that point),restoreInput()returns defaults; onlyonRestore()fires. So init args reach input widgets only viaonRestore+updateXxxInput(a flash), not the "auto-restore, no author code" the design/demo currently claims. (mcp/design-mcpConfigure.md:188-243,mcp/demo-app/app.R:60-62need correcting regardless.) resourceUri-args spike. A candidate fix for the init path: have theopen_*tool result point at a per-call resource URI carrying the args (e.g.ui://shiny/clock?_inputs_&label=Meeting); if the host passes that URI verbatim toresources/read, we parse it intomcpFakePageRequest()'sQUERY_STRINGand get flash-free widget restore in both tunnel and direct modes. Open question: does an MCP host honor a per-call resource URI from the tool result and pass a query-bearing URI through toresources/read? This same mechanism could also carry a correlation id for option 1(a) above. Worth a small host spike.
Open questions
- Session registry lifecycle: register on connect / remove on disconnect; keying (by
appId? global list?); handling reconnects. - Targeting policy default (most-recent vs broadcast) and how the explicit-handle path degrades when the handle is stale/unknown.
- Does
mcpUpdates()remain the right channel for server-pushed updates, or introduce a distinct server-push reactive? (Leaning: reusemcpUpdates()so existing app code works unchanged.) - Interaction with allow-list filtering (
mcpFilterArguments) — apply on the server-push path too.
Scope
New feature, separate from #4414. Warrants its own brainstorm/spec, with the session-registry lifecycle and targeting policy as the first decisions.
Activity
schloerke commentedon Jul 16, 2026
Real-host findings (Claude Desktop, stdio) — reshape this issue
Tested the demo app against Claude over the stdio transport with instrumented logging. Two decisive observations:
1. Claude re-renders per
open_*call — it does not live-update a running instance.Each tool call spun up a brand-new Shiny session (distinct
session$tokenvalues1ec8…then8820…). So a "change the parameter" request produces a fresh render, not an in-place update — exactly the behavior that motivates this issue. There is no persistent instance for a repeatopen_*to steer.2. The
resourceUri-with-args idea (from the parent PR discussion) does NOT work with this host.Claude issued
resources/readwith the static URI (ui://shiny/demo, no query), ~4ms before the tool call returned — it never used the per-callresourceUriwe put in the tool-call result's_meta.ui. So we can't smuggle args into the render, and flash-free init-arg widget restore is not achievable. (Details/decision in PR #4414 → the init path is being simplified to a singlemcpUpdates()channel; seemcp/limitations.md.)Implication for this issue
Because every change is a fresh render and there is no live-update channel the model can reach on its own, an explicit
update-style tool remains the right (and only) way to steer a running app in place. The design in this issue stands; the "session token returned byopen" correlation is still the interesting part, with the timing caveat already noted (the session doesn't exist atopen-call time).One extra data point for the design: init args currently reach a fresh session via
mcpUpdates()reliably (confirmed end-to-end against Claude), so a server-side push into that same channel — the mechanism proposed here for the update tool — is validated to work.docs(mcp): correct mcpConfigure(arguments) init-restore claim (#4417)
docs(mcp): mark host-passthrough + multi-instance confirmed in Claude…
feat(mcp): update a running Shiny MCP App in place via update_<appId>…