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, whose inputSchema is the same declared arguments allow-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 existing registerMcpTool() tools). The handler locates the running app session(s) and pushes the (allow-list-filtered) args into the same mcpUpdates() 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 reuses arguments end-to-end. No resources/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 into update_*(session = "...").
- Timing catch: at
tools/call open_* time the Shiny session does not exist yet (it's created when the iframe connects, after resources/read), so open cannot return a real session$token. Two ways around it:
- (a)
open returns 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 the resourceUri-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 to update_*. No spike dependency; relies on the model retaining/using it.
- 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 + update as a pair.
- Alternatively expose a helper (e.g.
mcpPushUpdate(session, args)) usable inside an author's own registerMcpTool() 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/read with no restore context (fake request, args unknown at that point), restoreInput() returns defaults; only onRestore() fires. So init args reach input widgets only via onRestore + 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-62 need correcting regardless.)
resourceUri-args spike. A candidate fix for the init path: have the open_* 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 to resources/read, we parse it into mcpFakePageRequest()'s QUERY_STRING and 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 to resources/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: reuse mcpUpdates() 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.
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
argumentsinmcpConfigure(), auto-register a companionupdate_<appId>_apptool that pushes new arguments into the running session'smcpUpdates()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_appwithlabel/pausedargs), asking Claude to "change the label" caused it to re-invokeopen_clock_app, rendering a fresh widget each time (the user saw multiple app instances in the transcript). Claude's own explanation:Root cause: the model's only lever on the app is calling a tool, and the only tool it has is
open_*. Whether a repeatopen_*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 hostui/notifications/tool-inputto the live iframe) is effectively never triggered by Claude. In practice the feature behaves as init-args-only.Giving the model an explicit
updateverb (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
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."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)) ... })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:
open_*returns a session id the model passes intoupdate_*(session = "...").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: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).mcpUpdateModelContext(data = list(session = session$token)); the model then passes it toupdate_*. No spike dependency; relies on the model retaining/using it.Recommend shipping most-recent/broadcast as the default and layering explicit handles (option 1) on top for multi-instance disambiguation.
Shape
open+updateas a pair.mcpPushUpdate(session, args)) usable inside an author's ownregisterMcpTool()handler, for full control.Related findings from #4414 to fold in
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
appId? global list?); handling reconnects.mcpUpdates()remain the right channel for server-pushed updates, or introduce a distinct server-push reactive? (Leaning: reusemcpUpdates()so existing app code works unchanged.)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.