Skip to content

MCP Apps: tool to update an already-running app in place (auto-registered update_<appId>_app) #4415

Description

@schloerke

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:

  1. 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.
  2. Most-recently-opened session — "update the one you just opened." Simple; good default for the common single-instance-per-conversation case.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Priority: LowLow-impact bug, docs polish, papercut, or unclear low-severity request.ai-triage:doneMarks an issue whose AI triage workflow is complete.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions