goose/documentation/blog/2025-09-08-turn-any-mcp-server-mcp-ui-compatible/index.mdx
Michael Neale 7449a96664
Some checks failed
Canary / Prepare Version (push) Waiting to run
Canary / build-cli (push) Blocked by required conditions
Canary / Upload Install Script (push) Blocked by required conditions
Canary / bundle-desktop (push) Blocked by required conditions
Canary / bundle-desktop-intel (push) Blocked by required conditions
Canary / bundle-desktop-linux (push) Blocked by required conditions
Canary / bundle-desktop-windows (push) Blocked by required conditions
Canary / Release (push) Blocked by required conditions
Unused Dependencies / machete (push) Waiting to run
CI / changes (push) Waiting to run
CI / Check Rust Code Format (push) Blocked by required conditions
CI / Build and Test Rust Project (push) Blocked by required conditions
CI / Build Rust Project on Windows (push) Waiting to run
CI / Lint Rust Code (push) Blocked by required conditions
CI / Check OpenAPI Schema is Up-to-Date (push) Blocked by required conditions
CI / Test and Lint Electron Desktop App (push) Blocked by required conditions
Live Provider Tests / check-fork (push) Waiting to run
Live Provider Tests / changes (push) Blocked by required conditions
Live Provider Tests / Build Binary (push) Blocked by required conditions
Live Provider Tests / Smoke Tests (push) Blocked by required conditions
Live Provider Tests / Smoke Tests (Code Execution) (push) Blocked by required conditions
Live Provider Tests / Compaction Tests (push) Blocked by required conditions
Live Provider Tests / goose server HTTP integration tests (push) Blocked by required conditions
Publish Docker Image / docker (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Cargo Deny / deny (push) Has been cancelled
Deploy Documentation / deploy (push) Has been cancelled
Publish Ask AI Bot Docker Image / docker (push) Has been cancelled
Publish to npm / Generate ACP Schema (push) Has been cancelled
Publish to npm / Build goose CLI (darwin-arm64) (push) Has been cancelled
Publish to npm / Build goose CLI (darwin-x64) (push) Has been cancelled
Publish to npm / Build goose CLI (linux-arm64) (push) Has been cancelled
Publish to npm / Build goose CLI (linux-x64) (push) Has been cancelled
Publish to npm / Release to npm (push) Has been cancelled
docs: rework homepage and add aaif migration blog post (#8356)
Signed-off-by: Michael Neale <michael.neale@gmail.com>
2026-04-07 07:18:04 +00:00

346 lines
No EOL
12 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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/goose-oss) and [GitHub Discussions](https://github.com/aaif-goose/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://goose-docs.ai/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://goose-docs.ai/assets/images/mcp-ui-0a7ec9ab9d9b8b0f84e1372e956cfbde.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta property="twitter:domain" content="goose-docs.ai" />
<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://goose-docs.ai/assets/images/mcp-ui-0a7ec9ab9d9b8b0f84e1372e956cfbde.png" />
</head>