decocms / decocms/studio

Proposal: Deep-link state for MCP apps in Studio

Open
#3,180 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
407
Forks
57
Avg merge
5h 6m
Merged PRs (30d)
880

Description

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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the ext-apps schema and the existing handlers and buildHostContext() in apps/mesh/src/mcp-apps/use-app-bridge.ts. Then trace MCPAppIframeRenderer in generic.tsx and unifiedChatSearchSchema in web/index.tsx to understand the current search-state flow. Done means state changes update the Studio URL, reloads restore appState through initialization, and the schema supports both notification directions.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
api, frontend
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.