-
Notifications
You must be signed in to change notification settings - Fork 5.4k
[docs] MCP-UI Blog Post #4578
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
[docs] MCP-UI Blog Post #4578
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
ee1eb2f
mcp-ui blog post
EbonyLouis 5ecd85f
update description
EbonyLouis 7cf4b4f
adding blog banner
EbonyLouis 69731a8
addressing pr comments
EbonyLouis f828d1c
adding ui actions
EbonyLouis c2b2212
clarity around where to put tool actions
EbonyLouis File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Binary file added
BIN
+970 KB
...log/2025-09-08-turn-any-mcp-server-mcp-ui-compatible/cloudinaryBefore&After.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added
BIN
+210 KB
...ation/blog/2025-09-08-turn-any-mcp-server-mcp-ui-compatible/filesystemAfter.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added
BIN
+109 KB
...tion/blog/2025-09-08-turn-any-mcp-server-mcp-ui-compatible/filesystemBefore.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
346 changes: 346 additions & 0 deletions
346
documentation/blog/2025-09-08-turn-any-mcp-server-mcp-ui-compatible/index.mdx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| --- | ||
|
|
||
|  | ||
|
|
||
| [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 | ||
| - One‑click buttons to copy or view links | ||
| - Transformation examples | ||
|
|
||
| With MCP-UI, it’s 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 | ||
|
|
||
| Here’s 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`. | ||
| It’s 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 host’s 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`. | ||
|
|
||
| That’s 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, you’ll either need to wait for more SDKs to drop or build your own bindings. | ||
| ::: | ||
|
|
||
| ## Step 3: My Cloudinary UI | ||
|
|
||
| Here’s the HTML generator I wrote for Cloudinary, this is where you decide exactly how your UI should look. | ||
|
|
||
| Instead of just telling you, let’s 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 | ||
|
|
||
|  | ||
|
|
||
| <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. That’s 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 didn’t write this tweet… goose did. (${result.resource_type} included). & here’s 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? [Here’s 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. | ||
| ::: | ||
|
|
||
|
|
||
|
|
||
|
EbonyLouis marked this conversation as resolved.
|
||
| ## 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. | ||
|
|
||
| Here’s the old version: | ||
|
|
||
| ```ts | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: JSON.stringify(response, null, 2) | ||
| } | ||
| ] | ||
| }; | ||
| ``` | ||
|
|
||
| And here’s 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' | ||
| }) | ||
| ] | ||
| }; | ||
| ``` | ||
|
|
||
| That’s it. One extra resource, and suddenly goose renders a full UI. | ||
|
|
||
| ## Filesystem: Same Pattern | ||
|
|
||
| To prove this wasn’t a one-off, I also made the Filesystem MCP server UI-compatible. | ||
|
|
||
| **Before:** Text output (what goose shows by default) | ||
|
|
||
|  | ||
|
|
||
| **After:** UI output (interactive explorer with MCP-UI) | ||
|
|
||
|  | ||
|
|
||
| And here’s 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 | ||
|
|
||
| I’ve 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, you’ll 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. It’s not the full vision yet, but it’s already enough to start building experiences that feel more like mini-apps than static responses. | ||
|
|
||
| That’s what excites me, we’re not waiting around. We’re 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). We’d 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 added
BIN
+984 KB
documentation/blog/2025-09-08-turn-any-mcp-server-mcp-ui-compatible/mcp-ui.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Binary file not shown.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.