[docs] MCP-UI Blog Post (#4578)

This commit is contained in:
Ebony Louis 2025-09-11 15:04:15 -04:00 committed by GitHub
parent d63781a552
commit cfce5f75b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 346 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View file

@ -0,0 +1,346 @@
---
title: "How to Make An MCP Server MCP-UI Compatible"
description: "How I made existing MCP servers MCP-UI compatible with just a few lines of code"
authors:
- ebony
---
![blog banner](mcp-ui.png)
[MCP-UI](https://mcpui.dev/guide/introduction) is in its infancy, and there's something addictive about being this early to the party. We're at this fascinating point where both the spec and client implementations are actively developing, and I find it thrilling to build alongside that evolution.
I wanted to see how far I could push it. So I grabbed two open source MCP servers, [Cloudinary](https://github.com/felores/cloudinary-mcp-server) and [Filesystem](https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem), and gave them a UI. Instead of boring text, I now get rich, interactive interfaces right inside goose.
<!-- truncate -->
## Why I Wanted This
Raw JSON and text is fine, it gets the job done but let's be real I'd rather interact with something pretty. Give me a cool UI over back and forth prompts.
Take Cloudinary for example. By default, uploads return a block of text, basically a JSON dump of URLs, metadata, and public IDs. Useful, sure, but not exactly easy to glance at.
What I really wanted was:
- Image and video previews
- Oneclick buttons to copy or view links
- Transformation examples
With MCP-UI, its not just text responses anymore. Now responses can be little apps you can actually click around in within your agent's chat interface.
{/* Video Player */}
<div style={{ width: '100%', maxWidth: '800px', margin: '0 auto' }}>
<video
controls
width="100%"
height="400px"
playsInline
>
<source src={require('@site/static/videos/cloudinary2.mp4').default} type="video/mp4" />
Your browser does not support the video tag.
</video>
</div>
## The Pattern
Heres the cool part, the steps are basically the same for any MCP server.
### **1. Install the SDK**
```bash
npm install @mcp-ui/server
```
### **2. Import it**
```ts
import { createUIResource } from "@mcp-ui/server";
```
### **3. Build your HTML**
For my Cloudinary server update, I used `Direct HTML → iframe`. I wrote a function that returns an HTML string that includes upload previews and action buttons.
MCP-UI takes that HTML and renders it inside an iframe using `srcdoc`.
Its simple, totally self-contained, fast to iterate, and I get full control over how it looks.
💡 However, other modes exist:
- **External URL** iframe a hosted page:
`content: { type: "externalUrl", iframeUrl }`
- **Remote DOM** send a script that builds UI directly in the hosts DOM:
`content: { type: "remoteDom", script, framework }`
But for my use case, **Direct HTML was the perfect fit.**
### **4. Return both**
In your tool handler, I recommend returning both the original response and the `createUIResource`.
Thats it. Regardless the server the main steps remain the same.
:::tip warning
Right now the MCP-UI SDK is only available in **TypeScript** and **Ruby**.
If your server is in one of those languages, you can start today.
If not, youll either need to wait for more SDKs to drop or build your own bindings.
:::
## Step 3: My Cloudinary UI
Heres the HTML generator I wrote for Cloudinary, this is where you decide exactly how your UI should look.
Instead of just telling you, lets look at the difference.
**Before MCP-UI (left):** An unstyled block of text with links and raw transformations
**After MCP-UI (right):** A clean layout with cute interactive cards & previews
![before vs after MCP-UI](cloudinaryBefore&After.png)
<details>
<summary>Click to see the code</summary>
```ts
private createUploadResultUI(result: UploadApiResponse): string {
const isImage = result.resource_type === 'image';
const isVideo = result.resource_type === 'video';
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cloudinary Upload Result</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 15px;
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #4CAF50, #45a049);
color: white;
padding: 30px;
text-align: center;
}
.content { padding: 30px; }
.preview-section { text-align: center; margin-bottom: 30px; }
.preview-section img, .preview-section video {
max-width: 100%; max-height: 300px; border-radius: 10px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
}
.actions { display: flex; gap: 15px; justify-content: center; flex-wrap: wrap; }
.btn { padding: 12px 24px; border-radius: 25px; color: white; border: none; cursor: pointer; }
.btn-primary { background: #007bff; }
.btn-success { background: #28a745; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div style="font-size:3em">✅</div>
<h1>Upload Successful!</h1>
</div>
<div class="content">
${isImage ? `<img src="${result.secure_url}" />` : ''}
${isVideo ? `<video controls><source src="${result.secure_url}" /></video>` : ''}
<div class="actions">
<a href="${result.secure_url}" target="_blank" class="btn btn-primary">🔗 View</a>
<button class="btn btn-success" onclick="navigator.clipboard.writeText('${result.secure_url}')">📋 Copy URL</button>
</div>
</div>
</div>
<script>
// highlight-start
const resizeObserver = new ResizeObserver((entries) => {
entries.forEach((entry) => {
window.parent.postMessage({
type: "ui-size-change",
payload: { height: entry.contentRect.height },
}, "*");
});
});
resizeObserver.observe(document.documentElement);
//highlight-end
</script>
</body>
</html>
`;
}
```
</details>
:::tip Resize your UI
Notice the `ResizeObserver` at the bottom of the HTML.
That little snippet is what keeps the iframe height in sync with your content so if your UI grows or shrinks, the window resizes automatically. Without it, your UI might look cut off and difficult to view.
:::
### What Makes MCP-UI Interactive?
A clean UI is nice, but it gets way more interesting when those buttons actually do something. Thats where **UI Actions** come in; they turn static layouts into interactive tools that can talk back to your agent.
{/* Video Player */}
<div style={{ width: '100%', maxWidth: '800px', margin: '0 auto' }}>
<video
controls
width="100%"
height="400px"
playsInline
>
<source src={require('@site/static/videos/cloudinaryaction.mp4').default} type="video/mp4" />
Your browser does not support the video tag.
</video>
</div>
In my Cloudinary server, I added **two** UI actions right after the `ResizeObserver` in the `<script>` block of `createUploadResultUI`:
- **Prompt Action** → Fires off a prompt to goose asking it to caption the image like a meme.
<details>
<summary>Click to see the code</summary>
```ts
function makeMeme() {
window.parent.postMessage({
type: "prompt",
payload: {
prompt: "Create a funny meme caption for this image. Make it humorous and engaging."
}
}, "*");
}
```
</details>
- **Link Action** → Opens Twitter with the uploaded image pre-linked so you can share it in one click.
<details>
<summary>Click to see the code</summary>
```ts
function shareOnTwitter() {
const tweetText = encodeURIComponent(
"I didnt write this tweet… goose did. (${result.resource_type} included). & heres how you can do it too 🧵 #MCPUI");
const imageUrl = encodeURIComponent("${result.secure_url}");
const twitterUrl = "https://twitter.com/intent/tweet?text=" + tweetText + "&url=" + imageUrl;
window.parent.postMessage({
type: "link",
payload: { url: twitterUrl }
}, "*");
}
```
</details>
> Want to see it live? [Heres the tweet goose posted for me](https://x.com/EbonyJLouis/status/1966203455955157337).
:::tip More UI Actions
Prompt and Link are just two examples. MCP-UI also supports **Tool**, **Intent**, and **Notify** actions.
:::
## Step 4: Look How Small the Diff Is
This is the part that blew my mind, making a tool UI-compatible is just a tiny code change.
Heres the old version:
```ts
return {
content: [
{
type: "text",
text: JSON.stringify(response, null, 2)
}
]
};
```
And heres the new version with MCP-UI support:
```ts
return {
content: [
{
type: "text",
text: `🎉 Upload successful!\n\n${JSON.stringify(response, null, 2)}`
},
createUIResource({
uri: `ui://cloudinary-upload/${result.public_id}`,
content: { type: 'rawHtml', htmlString: this.createUploadResultUI(result) },
encoding: 'text'
})
]
};
```
Thats it. One extra resource, and suddenly goose renders a full UI.
## Filesystem: Same Pattern
To prove this wasnt a one-off, I also made the Filesystem MCP server UI-compatible.
**Before:** Text output (what goose shows by default)
![before MCP-UI](filesystemBefore.png)
**After:** UI output (interactive explorer with MCP-UI)
![With MCP-UI](filesystemAfter.png)
And heres the only diff you need:
```ts
return {
content: [
{ type: "text", text: `📂 Files in ${directoryPath}:\n\n${textResponse}` },
createUIResource({
uri: `ui://filesystem/explorer/${encodeURIComponent(directoryPath)}`,
content: { type: "rawHtml", htmlString: htmlContent },
encoding: "text",
})
]
};
```
## Ahead of the Curve
Ive now made two MCP servers UI-compatible, before MCP-UI is even fully rolled out. That's crazy to me.
And if you zoom out, youll see other companies pushing here too. goose and Postman already support rendering and a couple of UI actions. In goose right now, a button can fire off a new prompt or open an external link. Its not the full vision yet, but its already enough to start building experiences that feel more like mini-apps than static responses.
Thats what excites me, were not waiting around. Were experimenting in the open, and shaping what the future will feel like.
---
## Try It Yourself
Wanna see it in action?
Download [goose](/docs/quickstart#install-goose), give an MCP server a UI facelift of your own, and see the magic for yourself. Boring text prompts will never hit the same again.
*Got questions?* Explore our [docs](/docs/category/guides), browse the [blog](/blog), or join the conversation in our [Discord](https://discord.gg/block-opensource) and [GitHub Discussions](https://github.com/block/goose/discussions). Wed love to have you.
<head>
<meta property="og:title" content="How to Make An MCP Server MCP-UI Compatible" />
<meta property="og:type" content="article" />
<meta property="og:url" content="https://block.github.io/goose/blog/2025/09/08/turn-any-mcp-server-mcp-ui-compatible" />
<meta property="og:description" content="How I made existing MCP servers MCP-UI compatible with just a few lines of code." />
<meta property="og:image" content="https://block.github.io/goose/assets/images/mcp-ui-0a7ec9ab9d9b8b0f84e1372e956cfbde.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta property="twitter:domain" content="block.github.io/goose" />
<meta name="twitter:title" content="How to Make An MCP Server MCP-UI Compatible" />
<meta name="twitter:description" content="How I made existing MCP servers MCP-UI compatible with just a few lines of code." />
<meta name="twitter:image" content="https://block.github.io/goose/assets/images/mcp-ui-0a7ec9ab9d9b8b0f84e1372e956cfbde.png" />
</head>

Binary file not shown.

After

Width:  |  Height:  |  Size: 984 KiB

Binary file not shown.

Binary file not shown.