Rendering widgets
Some tools return an interactive UI alongside their data — the track player,
uploaders, and editors that MCP-Apps hosts (like Claude or ChatGPT) render inline.
These are MCP UI resources
(ui://…), and you can render them in your own product to give users the same
experience.
You have two ways to use a tool’s output:
- Just the data. Successful tool calls return
structuredContent— render it however you like, in your own components. (Error results,isError: true, may omit it, so branch onisErrorfirst.) No host code required (see Server-to-server). - Our widget. Render the Slipstream widget itself, for the same inline experience an MCP-Apps host gives. This page covers that.
If your host already supports MCP Apps (like Claude or ChatGPT), these widgets render automatically — you don’t need any of the host code below. This page is for hosts that implement widget rendering themselves; the code is an illustrative reference, not a required stack.
How it works
A UI-capable tool declares its widget in its tools/list definition, under
_meta.ui.resourceUri. Its tools/call result carries the data to render:
// tools/list — the tool definition tells you WHICH widget
{ "name": "search_tracks",
"_meta": { "ui": { "resourceUri": "ui://slipstream/track-player.html" } } }
// tools/call — the result gives you the DATA
{ "structuredContent": { "tracks": [ /* … */ ] } }Rendering it is a handshake between your page (the host) and the widget, running in an iframe:
tool def (tools/list) ──▶ _meta.ui.resourceUri
│ resources/read
▼
widget HTML ──▶ iframe
│ postMessage bridge (host ↔ widget)
▼
host pushes the tool-call result ──▶ widget renders
▲
widget calls tools back ─────────┘ (play, clip, download…)The widget is a self-contained bundle built on the
MCP Apps extension. Your
host speaks the same protocol through the @modelcontextprotocol/ext-apps SDK — you
don’t build or bundle any of the UI yourself.
Where the token lives
Never put your server-to-server token in the browser. Keep it on your backend and
proxy tools/call and resources/read through it. The browser host talks only to
your backend.
Browser (host + iframe) ◀──▶ Your backend (holds the token) ◀──▶ Slipstream MCPPrerequisites
- A web frontend — widgets render in an iframe, so this is a browser feature. A purely server-side integration can’t render them (but can still use the data).
- A backend that holds your S2S token and forwards MCP calls (it already does, if
you use Server-to-server). Pass each end user’s
X-User-IDthrough. - The
@modelcontextprotocol/ext-appsSDK on the frontend.
Find the widget for a tool
A UI-capable tool names its widget in its tools/list definition, under
_meta.ui.resourceUri (the SDK’s getToolUiResourceUri() reads it for you). The
tools/call result carries only the data. To see every widget the server offers,
call resources/list and look for ui:// URIs — that list is the source of truth,
so you never hard-code it.
Today the server exposes these (call resources/list for the current set):
Widget (ui://slipstream/…) | Shown by |
|---|---|
track-player.html | track-returning tools — search, find-similar, track info, playlist tracks |
soundtrack-uploader.html | the soundtrack-a-video flow |
compliance-uploader.html | the music-compliance checker |
track-clip-editor.html | the clip editor |
track-extend-editor.html | the audio extender — not released yet |
license-preview.html | the generated licence agreement, with a download — not released yet |
The last two are still rolling out; resources/list won’t return them until they
are live. Build against what that call reports, not against this table.
Your backend: proxy two calls
Expose the MCP tools/call and resources/read to your frontend. The token and
X-User-ID stay here.
// backend — Node + the MCP TypeScript SDK
import express from 'express'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
const app = express()
app.use(express.json())
async function mcp(userId: string) {
const transport = new StreamableHTTPClientTransport(
new URL('https://mcp.slipstreammusic.com/mcp'),
{ requestInit: { headers: {
Authorization: `Bearer ${process.env.SLIPSTREAM_TOKEN}`,
'X-User-ID': userId,
} } },
)
const client = new Client({ name: 'your-app', version: '1.0.0' })
await client.connect(transport)
return client
}
// Your widgets' appearance. Travels per call, so set it here and every call
// carries it — see "Make it match your product".
const uiConfig = { accent: '#5b4cff', actions: { open: false } }
app.post('/api/mcp/tool', async (req, res) => {
const client = await mcp(req.user.id) // your auth → your end-user id
res.json(await client.callTool({
...req.body, // { name, arguments }
_meta: { ...req.body._meta, 'com.slipstreammusic/ui': uiConfig },
}))
})
app.post('/api/mcp/resource', async (req, res) => {
const client = await mcp(req.user.id)
res.json(await client.readResource({ uri: req.body.uri }))
})Your frontend: host the widget
Install the SDK:
npm install @modelcontextprotocol/ext-appsThe host advertises what it supports, reads the widget HTML, mounts it in an iframe, and pushes the tool result once the widget’s handshake completes.
import { AppBridge, PostMessageTransport } from '@modelcontextprotocol/ext-apps/app-bridge'
import type { McpUiStyles } from '@modelcontextprotocol/ext-apps'
const call = (path: string, body: unknown) =>
fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify(body) }).then(r => r.json())
// 1. Get the widget HTML and the tool result — both via your backend → MCP.
const widgetUri = 'ui://slipstream/track-player.html' // from search_tracks' tools/list definition
const { contents } = await call('/api/mcp/resource', { uri: widgetUri })
const widgetHtml: string = contents[0].text
const result = await call('/api/mcp/tool', { name: 'search_tracks', arguments: { query: 'chill' } })
// 2. Open the bridge. `null` = no in-browser MCP client; you answer its calls yourself.
const iframe = document.querySelector('#widget') as HTMLIFrameElement
const bridge = new AppBridge(
null,
{ name: 'Your App', version: '1.0.0' },
{ serverTools: {}, openLinks: {}, logging: {} }, // capabilities your host supports
{ hostContext: { // optional — see "Make it match your product"
styles: { variables: { '--font-sans': 'Inter, system-ui, sans-serif' } as McpUiStyles },
} },
)
// Forward what the widget asks for, back through your backend → MCP.
bridge.oncalltool = (params) => call('/api/mcp/tool', params) // serverTools: play, clip, download…
bridge.onopenlink = async ({ url }) => { // openLinks: open/listen/share/download links
window.open(url, '_blank', 'noopener,noreferrer') // apply your own allowlist / confirm dialog
return {}
}
bridge.addEventListener('sizechange', ({ height }) => { if (height) iframe.style.height = `${height}px` })
// Push the data once the widget's handshake completes.
bridge.addEventListener('initialized', () => {
bridge.sendToolInput({ arguments: { query: 'chill' } })
bridge.sendToolResult(result) // the widget renders isError results too
})
// 3. Connect BEFORE loading the widget, then load it into the frame.
const transport = new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!)
await bridge.connect(transport)
iframe.srcdoc = widgetHtmlConnect the bridge before the widget loads. The widget sends its initialize
handshake the moment it loads. If you set srcdoc first and connect after, that
message is lost and the widget hangs. Call bridge.connect(), then set srcdoc.
Advertise exactly the capabilities your widgets use, and register a handler for
each. A capability with no matching bridge.on* handler makes that widget action
fail silently. For the Slipstream widgets:
serverTools → oncalltool (play, clip, download), openLinks → onopenlink
(open/listen/share/download links), and — if you host the soundtrack uploader —
updateModelContext → onupdatemodelcontext (it hands the chosen track back to your
agent). Advertise those in the constructor and wire each handler. Do not advertise
capabilities the widgets don’t use.
Make it match your product
The widgets ship in Slipstream’s own look. Two levers change that, and they travel by different routes:
| You want | Set | Sent on |
|---|---|---|
| Your brand colour, and which track actions appear | _meta on tools/call | every tool call |
| Your typography | hostContext.styles on the bridge | the handshake |
Your brand colour and track actions
Send a com.slipstreammusic/ui object in the _meta of the tools/call params:
{
"name": "search_tracks",
"arguments": { "query": "chill" },
"_meta": {
"com.slipstreammusic/ui": {
"accent": "#5b4cff",
"actions": { "open": false }
}
}
}| Field | Value | Effect |
|---|---|---|
accent | #rrggbb | The primary colour — buttons, focus rings, playhead, waveform selection, and the tints derived from them. Six-digit hex only; #abc is rejected. |
actions | open, download, clip → boolean | false drops that action from the track row. An omitted key leaves it visible. |
actions can only take things away. A row that has nothing to download stays
without a download button no matter what you send, and true never adds an action
back — it is accepted so you can list all three explicitly, but it changes nothing.
The two fields reach different widgets, because only some of them render track rows:
| track player | soundtrack uploader | clip editor | compliance uploader | |
|---|---|---|---|---|
accent | ✓ | ✓ | ✓ | — |
actions | ✓ | ✓ | — | — |
clip is only ever rendered by the track player’s main result list, so switching it
off is a no-op on stem and version lists and in the soundtrack uploader.
The server echoes the sanitised config back on the tool result, at
structuredContent.ui_config — not on the result’s _meta. If you render our
widgets you can ignore that; if you are building your own UI from the data, that is
where to read it.
Anything malformed — a bad hex, an unknown action name, a non-boolean — is ignored and that part falls back to the Slipstream default. A typo costs you the styling, never the row.
Send it on every tools/call, not just the first. MCP is stateless: the server
does not carry it between calls. A call that omits it doesn’t merely render
unstyled — it repaints an already-mounted widget back to Slipstream’s colours
mid-session, which is the symptom you’d end up reporting. Nothing errors. Set it
once in your backend proxy — as in the /api/mcp/tool handler above — so it also
covers the calls the widget itself makes through oncalltool.
Because it rides each call rather than your account, it can differ per call — one end customer’s colour on their campaign, another’s on theirs.
Your typography
hostContext is the standard MCP-Apps channel for a host to describe its
environment to a widget. Pass it as the bridge’s fourth argument:
import type { McpUiStyles } from '@modelcontextprotocol/ext-apps'
const bridge = new AppBridge(
null,
{ name: 'Your App', version: '1.0.0' },
{ serverTools: {}, openLinks: {} },
{ hostContext: {
// `McpUiStyles` is a Record over every style-variable key, so a partial
// object needs the cast — TypeScript otherwise asks for all 75.
styles: { variables: { '--font-sans': 'Inter, system-ui, sans-serif' } as McpUiStyles },
} },
)--font-sans sets the widgets’ typeface. The widget runs in its own document and
inherits none of your page’s CSS, so give it a stack that resolves on its own —
system faces, or one already installed on the user’s machine.
To follow a change after mount — your theme switches, the panel resizes — call
bridge.setHostContext({ … }). Prefer it over the lower-level
sendHostContextChange: setHostContext also updates the context the bridge
stores, so a widget that remounts is replayed the current values rather than the
ones you passed at construction.
A webfont needs your origin on our CSP. hostContext.styles.css.fonts injects
your @font-face rules into the widget, but the font file is fetched under the
frame’s CSP, and the widget resource declares only Slipstream origins in
_meta.ui.csp. On a plain srcdoc load — the default below — nothing enforces
that CSP and the font loads. Behind a CSP-enforcing sandbox it is blocked, and
there is no way around it from your side: resourceDomains produces no data:
source, so an inlined face is blocked too, and an @import from a font CDN is
blocked by style-src before font-src is ever reached. Ask us to add your
origin. Until then, --font-sans with a system stack is the option that always
works.
What isn’t supported yet. Beyond --font-sans, the style variables in the
MCP-Apps set are accepted but nothing reads them yet, so they have no effect. If a
specific one matters for your embed, tell us and we will prioritise it.
Security (optional)
The widgets are trusted first-party Slipstream UI, so the plain srcdoc load above
works with nothing to configure. Whether to isolate it further is your app’s
security choice, not a Slipstream requirement:
- To isolate it, sandbox the frame —
iframe.setAttribute('sandbox', 'allow-scripts')gives the widget an opaque origin with no access to your DOM, cookies, or storage. Caveat: at a strict opaque origin the widget’s cross-origin asset fetches (waveforms) don’t load — use the SDK’s sandbox-proxy, which isolates the widget and enforces the declared CSP so assets still load. It’s the standard MCP-Apps host pattern.
Each widget resource declares the origins it needs under _meta.ui.csp (on the
resources/list entry and the resources/read content _meta) — allow those if you
enforce a CSP on the frame. resourceDomains also feeds script-src / style-src, so
treat any origin you allow as trusted to serve executable code.
Lifecycle
- Errors. A tool result may carry
isError: true; still callsendToolResult— the widget renders the error state. SendsendToolCancelled({ reason })(reasonis optional) when a call is cancelled or interrupted before completing — user cancel, timeout, sampling error, or classifier intervention. - Teardown. Before unmounting the iframe,
await bridge.teardownResource({})so the widget can stop audio and clean up, then remove the frame.
Reference
- MCP Apps overview and specification
@modelcontextprotocol/ext-apps—AppBridge,PostMessageTransport,getToolUiResourceUri, and the sandbox-proxy flow.AppBridgeis framework-agnostic; wrap it in a React effect if you use React.- Server-to-server — the token and
X-User-IDyour backend uses