Proposal: Deep-link state for MCP apps in Studio
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:
- 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.appStatewhen it sendsui/initialize's response. - App: App reads
hostContext.appStateon 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}>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.- 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-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-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-changedon every state mutation (via$watch). - Reads
hostContext.appStatefrom theui/initializeresponse 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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