Skip to content

Proposal: Deep-link state for MCP apps in Studio #3180

@vibegui

Description

@vibegui

Proposal: Deep-link state for MCP apps in Studio

Date: 2026-04-25
Author: Guilherme (CEO agent assist)
Status: Open — for the Studio team
Audience: Mesh / Studio engineering

Why

When you open an MCP app in Studio (e.g. the CEO agent's UI), you can navigate inside it — pick a domain, open a conversation, view a briefing, run a search. Today none of that state is reflected in the Studio URL, so:

  • You can't share "look at this conversation" with a colleague — pasting the URL drops them at the home view.
  • You can't bookmark a specific view (the Linear hygiene strategy conversation, for example).
  • You lose state on refresh — Studio reloads, the iframe reloads, you're back at the entry point.
  • You can't link from chat to UI state — agents can't say "I parked the analysis here" with a clickable link.

The deeper rationale: the conversation network only works if it's referenceable. If we can't link to a specific conversation in the UI, the network exists but isn't navigable from outside.

What

Allow MCP apps to push and restore state through Studio's URL.

Concretely:

  1. App → host: App emits a state-changed notification when its internal route changes.
  2. Host: Studio captures the state and persists it in a search param (e.g. &appState=<base64>).
  3. Host → app: On (re)load, Studio reads the search param and includes it in hostContext.appState when it sends ui/initialize's response.
  4. App: App reads hostContext.appState on init and restores its view.

Result: a Studio URL like

http://localhost:3000/<org>/<task>?virtualmcpid=vir_xxx&appState=eyJ2IjoiY29udmVyc2F0aW9uIiwiZCI6InN0cmF0ZWd5IiwiYyI6ImxpbmVhci1oeWdpZW5lLTIwMjYifQ==

…opens the CEO app with the strategy/linear-hygiene-2026 conversation already loaded.

Where the gap is today

After exploring ~/Projects/mesh/apps/mesh/src/mcp-apps/:

  • mcp-app-renderer.tsx — renders <iframe srcDoc={html}> with sandbox="allow-scripts allow-same-origin allow-forms allow-popups" and CSP injection. Has no notion of app state.
  • use-app-bridge.ts — registers handlers for onmessage, onupdatemodelcontext, onsizechange, ondownloadfile, onloggingmessage. No onappstatechange handler.
  • buildHostContext() (lines ~59–79 of use-app-bridge.ts) — builds McpUiHostContext with theme, toolInfo, displayMode, containerDimensions, etc. No appState field.
  • Studio routing (web/layouts/agent-shell-layout/index.tsx) — TanStack Router with search params (virtualmcpid, tab, main). No URL hash handling. grep "location.hash" returns zero hits in the codebase.
  • @modelcontextprotocol/ext-apps schema (schema.d.ts) — defines these notifications: sandbox-proxy-ready, size-changed, tool-input, tool-input-partial, tool-cancelled, initialized, sandbox-resource-ready, tool-result, host-context-changed. No state-changed.

So today there's no protocol primitive AND no host-side capture path. Both need to land.

Concrete changes

1. Add state-changed to the ext-apps schema

In @modelcontextprotocol/ext-apps:

export const McpUiStateChangedNotificationSchema = z.object({
  method: z.literal("ui/notifications/state-changed"),
  params: z.object({
    state: z.unknown(), // app-defined; treat as opaque JSON
  }),
});

And add to McpUiHostContext:

appState: z.unknown().optional(), // populated from URL by host on init

This is the only protocol-level change. Both directions (app→host and host→app) need it.

2. Wire it in Studio

apps/mesh/src/mcp-apps/use-app-bridge.tsregisterHandlers() (around line 320):

bridge.onappstatechange = ({ state }) => {
  if (this.disposed || !this.config.onAppStateChange) return;
  this.config.onAppStateChange(state);
  return {};
};

Same file — buildHostContext() (around line 59–79):

function buildHostContext(
  displayMode: McpUiDisplayMode,
  toolInfo?: McpUiHostContext["toolInfo"],
  maxHeight?: number,
  orgId?: string,
  appState?: unknown, // NEW
): McpUiHostContext {
  return {
    ...,
    ...(appState !== undefined && { appState }),
  };
}

Same file — attach() (around line 250–296):

const { appState } = this.config; // NEW
const hostContext = buildHostContext(displayMode, toolInfo, maxHeight, orgId, appState);

apps/mesh/src/web/components/chat/message/parts/tool-call-part/generic.tsx (around line 368) — pass the bridge:

const search = useSearch({ strict: false });
const navigate = useNavigate();

<MCPAppIframeRenderer
  ...
  appState={search.appState ? JSON.parse(atob(search.appState)) : undefined}
  onAppStateChange={(state) => {
    const encoded = btoa(JSON.stringify(state));
    navigate({
      to: ".",
      search: (prev) => ({ ...prev, appState: encoded }),
      replace: true,  // don't pollute history with every state tick
    });
  }}
/>

unifiedChatSearchSchema in web/index.tsx:

const unifiedChatSearchSchema = z.object({
  virtualmcpid: z.string().optional(),
  appState: z.string().optional(), // NEW
  ...
});

That's the entire host-side change — three files, ~30 lines of code.

3. App side (already done — see mcp/app.html in context repo)

The CEO MCP app already:

  • Tracks its own state in Alpine (view, activeDomain, activeConversation, activeBriefing, glossarySearch).
  • Emits ui/notifications/state-changed on every state mutation (via $watch).
  • Reads hostContext.appState from the ui/initialize response on load and restores the view.
  • Falls back to window.location.hash#mcpapp=... when running outside Studio (direct browser).
  • Has a "share" button in the topbar that copies a URL with the encoded state.

Right now the emit is a no-op in Studio because nothing's listening. The day the Studio change ships, deep links start working with zero changes on our side.

Implementation complexity

Piece Effort Risk
Add state-changed notification + appState field to ext-apps schema Small (1 PR upstream) Coordination with MCP SDK maintainers
Wire onappstatechange into AppBridge in mesh Small Mirror existing onsizechange pattern
Persist to / read from search param Small URL length limits — base64'd state should be capped at ~2KB
Pass appState into host context on init Small Already a prop passing pattern

Total: a few days of work, mostly waiting on the SDK PR. The mesh-side change is straightforward.

URL-length consideration

App state should be opaque, small, restorable (think SPA route + query, not full content). The CEO app's state is currently ~30 bytes raw, ~50 base64. Cap at ~1KB to be safe; if an app needs more, it should persist server-side and store an ID.

Why use search param vs. URL hash

  • TanStack Router (Studio's router) treats search params as first-class state with type-safe schemas.
  • Hash fragments aren't sent to the server and aren't part of the route — they'd be a parallel, untyped state system.
  • Search params survive page reload, navigation, deep links from chat, and SSR (if ever needed).

Out of scope (intentionally)

  • Bidirectional cursor/scroll sync between app and host
  • Persisting app state to the chat thread record (separate proposal)
  • Server-side state IDs for large state (separate proposal)

Owner and timeline

Asking the Studio team to pick this up. The CEO MCP app is forward-compatible — when this lands, every navigation produces a shareable link automatically. Other native MCP apps in the ecosystem benefit identically by adopting the same handshake.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions