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:
- App → host: App emits a state-changed notification when its internal route changes.
- Host: Studio captures the state and persists it in a search param (e.g.
&appState=<base64>).
- Host → app: On (re)load, Studio reads the search param and includes it in
hostContext.appState when it sends ui/initialize's response.
- 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.ts — registerHandlers() (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.
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:
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:
&appState=<base64>).hostContext.appStatewhen it sendsui/initialize's response.hostContext.appStateon init and restores its view.Result: a Studio URL like
…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}>withsandbox="allow-scripts allow-same-origin allow-forms allow-popups"and CSP injection. Has no notion of app state.use-app-bridge.ts— registers handlers foronmessage,onupdatemodelcontext,onsizechange,ondownloadfile,onloggingmessage. Noonappstatechangehandler.buildHostContext()(lines ~59–79 ofuse-app-bridge.ts) — buildsMcpUiHostContextwiththeme,toolInfo,displayMode,containerDimensions, etc. NoappStatefield.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-appsschema (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. Nostate-changed.So today there's no protocol primitive AND no host-side capture path. Both need to land.
Concrete changes
1. Add
state-changedto the ext-apps schemaIn
@modelcontextprotocol/ext-apps:And add to
McpUiHostContext: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.ts—registerHandlers()(around line 320):Same file —
buildHostContext()(around line 59–79):Same file —
attach()(around line 250–296):apps/mesh/src/web/components/chat/message/parts/tool-call-part/generic.tsx(around line 368) — pass the bridge:unifiedChatSearchSchemainweb/index.tsx:That's the entire host-side change — three files, ~30 lines of code.
3. App side (already done — see
mcp/app.htmlin context repo)The CEO MCP app already:
view,activeDomain,activeConversation,activeBriefing,glossarySearch).ui/notifications/state-changedon every state mutation (via$watch).hostContext.appStatefrom theui/initializeresponse on load and restores the view.window.location.hash#mcpapp=...when running outside Studio (direct browser).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
state-changednotification +appStatefield to ext-apps schemaonappstatechangeinto AppBridge in meshonsizechangepatternappStateinto host context on initTotal: 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
Out of scope (intentionally)
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.