MCPRendering widgets

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 on isError first.) 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 MCP

Prerequisites

  • 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-ID through.
  • The @modelcontextprotocol/ext-apps SDK 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.htmltrack-returning tools — search, find-similar, track info, playlist tracks
soundtrack-uploader.htmlthe soundtrack-a-video flow
compliance-uploader.htmlthe music-compliance checker
track-clip-editor.htmlthe clip editor
track-extend-editor.htmlthe audio extender — not released yet
license-preview.htmlthe 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-apps

The 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 = widgetHtml
⚠️

Connect 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: serverToolsoncalltool (play, clip, download), openLinksonopenlink (open/listen/share/download links), and — if you host the soundtrack uploaderupdateModelContextonupdatemodelcontext (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 wantSetSent on
Your brand colour, and which track actions appear_meta on tools/callevery tool call
Your typographyhostContext.styles on the bridgethe 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 }
    }
  }
}
FieldValueEffect
accent#rrggbbThe primary colour — buttons, focus rings, playhead, waveform selection, and the tints derived from them. Six-digit hex only; #abc is rejected.
actionsopen, download, clip → booleanfalse 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 playersoundtrack uploaderclip editorcompliance 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 call sendToolResult — the widget renders the error state. Send sendToolCancelled({ reason }) (reason is 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-appsAppBridge, PostMessageTransport, getToolUiResourceUri, and the sandbox-proxy flow. AppBridge is framework-agnostic; wrap it in a React effect if you use React.
  • Server-to-server — the token and X-User-ID your backend uses