Follow Discord
Sweep 22 Oct 2026 · 21:04Z Build v2.1.309 528 read Stable v2.1.308 Latest v2.1.309 Next v2.1.309 Feeds RSS JSON llms.txt llms-full.txt Unofficial
One change · claude-docs

Add an interactive UI to your MCP server changedconnectors/building/mcp-apps/quickstart

Nearest release: v2.1.283, published under an hour after upstream edited the page. Shown because the two are within 24 hours of each other. Nothing here says the release caused the edit.

Upstream edited this page at 25 Sep 2026 18:00 UTC, give or take a minute or two: the time comes from Anthropic’s own sitemap rather than from a commit. This site recorded the change at 20 Oct 2026 18:07 UTC.

Upstream edited
Recorded here
Lines+255added
Lines−259removed
From line 6 where the diff opens
First seen 25 Sep 2026 this site's first read of the page
Recorded edits3to this page, all time

The whole hunk

from line 6, old and new numbered
/
lines

The two sides of this change are more than 400 edits apart, too far apart to line up, so this is the differ's own diff of it and the words inside a line are not marked.

from line 6
66 
77This quickstart is for developers who finished the first quickstart and have its server on their machine. It stops at what you can verify locally: seeing the UI render needs the server hosted where Claude can reach it, which the last section covers.
88 
9<Note>
10 * If you haven't built the quickstart server yet, start with [Build your first MCP server for Claude](/docs/connectors/building/quickstart)
11 * If you want to see finished MCP Apps running in Claude first, see [Try an example MCP App in Claude Desktop](/docs/connectors/building/mcp-apps/getting-started#try-an-example-mcp-app-in-claude-desktop)
12 * If you're adding UI to your own production server, see [Build your own MCP App](/docs/connectors/building/mcp-apps/getting-started#build-your-own-mcp-app) for the SDK documentation and full examples
13</Note>
9> [!NOTE]
10>
11> * If you haven't built the quickstart server yet, start with [Build your first MCP server for Claude](/docs/connectors/building/quickstart)
12> * If you want to see finished MCP Apps running in Claude first, see [Try an example MCP App in Claude Desktop](/docs/connectors/building/mcp-apps/getting-started#try-an-example-mcp-app-in-claude-desktop)
13> * If you're adding UI to your own production server, see [Build your own MCP App](/docs/connectors/building/mcp-apps/getting-started#build-your-own-mcp-app) for the SDK documentation and full examples
1414 
1515## Install the MCP Apps SDK
1616 
from line 39
3939 
4040Make these edits to `server.mjs` in order. If you'd rather paste the whole file, it's in [The complete file](#the-complete-file) at the end of this section.
4141 
42<Steps>
43 <Step title="Import the server helpers">
44 Add one import under the existing SDK imports at the top of `server.mjs`. `registerAppTool` and `registerAppResource` wrap the SDK's own `registerTool` and `registerResource` and fill in the UI metadata for you:
45 
46 ```js server.mjs {4} theme={null}
47 import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
48 import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
49 import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
50 import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
51 import { z } from 'zod';
52 ```
53 </Step>
54 
55 <Step title="Write the UI as an HTML string">
56 Below the imports and above `function buildServer()`, add the resource URI and the HTML the host will render. The HTML is ordinary markup plus one `<script type="module">`. The highlighted lines are the ones that make it an MCP App: the script loads the SDK's browser client, `App`, from a CDN copy of the version you installed, registers handlers for the tool's input and result, wires the button to call the tool again through the host, and then connects:
57 
58 ```js server.mjs {1,16,18,26-29} theme={null}
59 const DICE_UI = 'ui://quickstart/dice'; // MCP App resources use the ui:// scheme
60 
61 const DICE_HTML = `<!DOCTYPE html>
62 <html><head><meta charset="utf-8"><meta name="color-scheme" content="light dark"><title>Dice</title>
63 <style>
64 body { font: 14px system-ui, sans-serif; margin: 0; padding: 12px; }
65 #dice { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
66 .die { width: 44px; height: 44px; border: 2px solid; border-radius: 10px; display: grid; place-items: center; font-size: 18px; font-weight: 600; }
67 </style></head>
68 <body>
69 <div id="summary">Waiting for a roll…</div>
70 <div id="dice"></div>
71 <button id="again" disabled>Roll again</button>
72 <script type="module">
73 // The in-frame client. Loading the prebuilt bundle from a CDN means no bundler in this project.
74 import { App } from "https://unpkg.com/@modelcontextprotocol/[email protected]/dist/src/app-with-deps.js";
75 const $ = (id) => document.getElementById(id);
76 const app = new App({ name: "Dice View", version: "1.0.0" });
77 let lastArgs = { sides: 6, count: 1 };
78 const show = ({ structuredContent: sc, content }) => {
79 $("summary").textContent = sc ? sc.count + "d" + sc.sides + " → total " + sc.total : (content?.[0]?.text ?? "?");
80 $("dice").replaceChildren(...(sc?.rolls ?? []).map((n) => Object.assign(document.createElement("div"), { className: "die", textContent: n })));
81 $("again").disabled = false;
42**1. Import the server helpers**
43 
44Add one import under the existing SDK imports at the top of `server.mjs`. `registerAppTool` and `registerAppResource` wrap the SDK's own `registerTool` and `registerResource` and fill in the UI metadata for you:
45 
46```js server.mjs {4} theme={null}
47import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
48import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
49import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
50import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
51import { z } from 'zod';
52```
53 
54**2. Write the UI as an HTML string**
55 
56Below the imports and above `function buildServer()`, add the resource URI and the HTML the host will render. The HTML is ordinary markup plus one `<script type="module">`. The highlighted lines are the ones that make it an MCP App: the script loads the SDK's browser client, `App`, from a CDN copy of the version you installed, registers handlers for the tool's input and result, wires the button to call the tool again through the host, and then connects:
57 
58```js server.mjs {1,16,18,26-29} theme={null}
59const DICE_UI = 'ui://quickstart/dice'; // MCP App resources use the ui:// scheme
60 
61const DICE_HTML = `<!DOCTYPE html>
62<html><head><meta charset="utf-8"><meta name="color-scheme" content="light dark"><title>Dice</title>
63<style>
64 body { font: 14px system-ui, sans-serif; margin: 0; padding: 12px; }
65 #dice { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
66 .die { width: 44px; height: 44px; border: 2px solid; border-radius: 10px; display: grid; place-items: center; font-size: 18px; font-weight: 600; }
67</style></head>
68<body>
69<div id="summary">Waiting for a roll…</div>
70<div id="dice"></div>
71<button id="again" disabled>Roll again</button>
72<script type="module">
73 // The in-frame client. Loading the prebuilt bundle from a CDN means no bundler in this project.
74 import { App } from "https://unpkg.com/@modelcontextprotocol/[email protected]/dist/src/app-with-deps.js";
75 const $ = (id) => document.getElementById(id);
76 const app = new App({ name: "Dice View", version: "1.0.0" });
77 let lastArgs = { sides: 6, count: 1 };
78 const show = ({ structuredContent: sc, content }) => {
79 $("summary").textContent = sc ? sc.count + "d" + sc.sides + " → total " + sc.total : (content?.[0]?.text ?? "?");
80 $("dice").replaceChildren(...(sc?.rolls ?? []).map((n) => Object.assign(document.createElement("div"), { className: "die", textContent: n })));
81 $("again").disabled = false;
82 };
83 // Set handlers before connect() so the first tool-input and tool-result notifications aren't missed.
84 app.ontoolinput = ({ arguments: args }) => { if (args) lastArgs = { ...lastArgs, ...args }; };
85 app.ontoolresult = show; // the host pushes the tool result here; draw it
86 $("again").onclick = async () => show(await app.callServerTool({ name: "roll_dice", arguments: lastArgs }));
87 await app.connect(); // handshake with the host
88</script>
89</body></html>`;
90```
91 
92If you change the installed `ext-apps` version, change the version in the `unpkg.com` URL to match.
93 
94**3. Link the tool to the UI**
95 
96Inside `buildServer()`, replace the `server.registerTool(` call with `registerAppTool(server,` and make two additions, both highlighted: a `_meta.ui.resourceUri` entry that names the UI resource, and a `structuredContent` object in the result. The UI reads `structuredContent` to draw the dice, and the `content` text stays for hosts that don't render UI:
97 
98```js server.mjs {1-2,11,17-18} theme={null}
99 registerAppTool(
100 server,
101 'roll_dice',
102 {
103 title: 'Roll dice',
104 description: 'Roll `count` dice with `sides` sides each. Returns each roll and the total.',
105 inputSchema: {
106 sides: z.number().int().min(2).max(100).describe('Number of sides per die (2-100)'),
107 count: z.number().int().min(1).max(10).default(1).describe('How many dice to roll (1-10)'),
108 },
109 _meta: { ui: { resourceUri: DICE_UI } }, // tells the host which resource renders this tool
110 },
111 async ({ sides, count }) => {
112 const rolls = Array.from({ length: count }, () => 1 + Math.floor(Math.random() * sides));
113 const total = rolls.reduce((a, b) => a + b, 0);
114 return {
115 content: [{ type: 'text', text: `Rolled ${count}d${sides}: [${rolls.join(', ')}] total=${total}` }], // text-only hosts read this
116 structuredContent: { sides, count, rolls, total }, // the UI reads this
82117 };
83 // Set handlers before connect() so the first tool-input and tool-result notifications aren't missed.
84 app.ontoolinput = ({ arguments: args }) => { if (args) lastArgs = { ...lastArgs, ...args }; };
85 app.ontoolresult = show; // the host pushes the tool result here; draw it
86 $("again").onclick = async () => show(await app.callServerTool({ name: "roll_dice", arguments: lastArgs }));
87 await app.connect(); // handshake with the host
88 </script>
89 </body></html>`;
90 ```
91 
92 If you change the installed `ext-apps` version, change the version in the `unpkg.com` URL to match.
93 </Step>
94 
95 <Step title="Link the tool to the UI">
96 Inside `buildServer()`, replace the `server.registerTool(` call with `registerAppTool(server,` and make two additions, both highlighted: a `_meta.ui.resourceUri` entry that names the UI resource, and a `structuredContent` object in the result. The UI reads `structuredContent` to draw the dice, and the `content` text stays for hosts that don't render UI:
97 
98 ```js server.mjs {1-2,11,17-18} theme={null}
99 registerAppTool(
100 server,
101 'roll_dice',
102 {
103 title: 'Roll dice',
104 description: 'Roll `count` dice with `sides` sides each. Returns each roll and the total.',
105 inputSchema: {
106 sides: z.number().int().min(2).max(100).describe('Number of sides per die (2-100)'),
107 count: z.number().int().min(1).max(10).default(1).describe('How many dice to roll (1-10)'),
108 },
109 _meta: { ui: { resourceUri: DICE_UI } }, // tells the host which resource renders this tool
110 },
111 async ({ sides, count }) => {
112 const rolls = Array.from({ length: count }, () => 1 + Math.floor(Math.random() * sides));
113 const total = rolls.reduce((a, b) => a + b, 0);
114 return {
115 content: [{ type: 'text', text: `Rolled ${count}d${sides}: [${rolls.join(', ')}] total=${total}` }], // text-only hosts read this
116 structuredContent: { sides, count, rolls, total }, // the UI reads this
117 };
118 },
119 );
120 ```
121 </Step>
122 
123 <Step title="Register the UI resource">
124 Still inside `buildServer()`, before `return server;`, register the resource that serves the HTML. `registerAppResource` sets the MIME type an MCP App resource must have, `text/html;profile=mcp-app`, which the SDK exports as `RESOURCE_MIME_TYPE`. The highlighted `csp` line tells the host's sandbox to allow scripts from `unpkg.com`, because by default the frame only runs scripts that are inline or from its own origin, and the `App` import would be blocked. For production, serve the client script from your own origin, or bundle it into the HTML, and list only that origin in `resourceDomains`:
125 
126 ```js server.mjs {4,6} theme={null}
127 registerAppResource(server, 'Dice view', DICE_UI, { description: 'Interactive view for roll_dice' }, async () => ({
128 contents: [{
129 uri: DICE_UI,
130 mimeType: RESOURCE_MIME_TYPE,
131 text: DICE_HTML,
132 _meta: { ui: { csp: { resourceDomains: ['https://unpkg.com'] } } }, // let the sandbox load the App script
133 }],
134 }));
135 return server;
136 ```
137 </Step>
138</Steps>
118 },
119 );
120```
121 
122**4. Register the UI resource**
123 
124Still inside `buildServer()`, before `return server;`, register the resource that serves the HTML. `registerAppResource` sets the MIME type an MCP App resource must have, `text/html;profile=mcp-app`, which the SDK exports as `RESOURCE_MIME_TYPE`. The highlighted `csp` line tells the host's sandbox to allow scripts from `unpkg.com`, because by default the frame only runs scripts that are inline or from its own origin, and the `App` import would be blocked. For production, serve the client script from your own origin, or bundle it into the HTML, and list only that origin in `resourceDomains`:
125 
126```js server.mjs {4,6} theme={null}
127 registerAppResource(server, 'Dice view', DICE_UI, { description: 'Interactive view for roll_dice' }, async () => ({
128 contents: [{
129 uri: DICE_UI,
130 mimeType: RESOURCE_MIME_TYPE,
131 text: DICE_HTML,
132 _meta: { ui: { csp: { resourceDomains: ['https://unpkg.com'] } } }, // let the sandbox load the App script
133 }],
134 }));
135 return server;
136```
139137 
140138Run `node --check server.mjs` to catch typos. It prints nothing when the file parses.
141139 
from line 141
143141 
144142For reference, this is `server.mjs` with every edit applied. Everything below `buildServer()` is unchanged from the first quickstart.
145143 
146<Accordion title="server.mjs with the MCP App added">
147 ```js server.mjs theme={null}
148 import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
149 import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
150 import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
151 import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
152 import { z } from 'zod';
153 
154 const DICE_UI = 'ui://quickstart/dice'; // MCP App resources use the ui:// scheme
155 
156 const DICE_HTML = `<!DOCTYPE html>
157 <html><head><meta charset="utf-8"><meta name="color-scheme" content="light dark"><title>Dice</title>
158 <style>
159 body { font: 14px system-ui, sans-serif; margin: 0; padding: 12px; }
160 #dice { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
161 .die { width: 44px; height: 44px; border: 2px solid; border-radius: 10px; display: grid; place-items: center; font-size: 18px; font-weight: 600; }
162 </style></head>
163 <body>
164 <div id="summary">Waiting for a roll…</div>
165 <div id="dice"></div>
166 <button id="again" disabled>Roll again</button>
167 <script type="module">
168 // The in-frame client. Loading the prebuilt bundle from a CDN means no bundler in this project.
169 import { App } from "https://unpkg.com/@modelcontextprotocol/[email protected]/dist/src/app-with-deps.js";
170 const $ = (id) => document.getElementById(id);
171 const app = new App({ name: "Dice View", version: "1.0.0" });
172 let lastArgs = { sides: 6, count: 1 };
173 const show = ({ structuredContent: sc, content }) => {
174 $("summary").textContent = sc ? sc.count + "d" + sc.sides + " → total " + sc.total : (content?.[0]?.text ?? "?");
175 $("dice").replaceChildren(...(sc?.rolls ?? []).map((n) => Object.assign(document.createElement("div"), { className: "die", textContent: n })));
176 $("again").disabled = false;
177 };
178 // Set handlers before connect() so the first tool-input and tool-result notifications aren't missed.
179 app.ontoolinput = ({ arguments: args }) => { if (args) lastArgs = { ...lastArgs, ...args }; };
180 app.ontoolresult = show; // the host pushes the tool result here; draw it
181 $("again").onclick = async () => show(await app.callServerTool({ name: "roll_dice", arguments: lastArgs }));
182 await app.connect(); // handshake with the host
183 </script>
184 </body></html>`;
185 
186 function buildServer() {
187 const server = new McpServer({ name: 'quickstart-server', version: '1.0.0' }); // the name Claude sees
188 
189 registerAppTool(
190 server,
191 'roll_dice',
192 {
193 title: 'Roll dice',
194 description: 'Roll `count` dice with `sides` sides each. Returns each roll and the total.',
195 inputSchema: {
196 sides: z.number().int().min(2).max(100).describe('Number of sides per die (2-100)'),
197 count: z.number().int().min(1).max(10).default(1).describe('How many dice to roll (1-10)'),
198 },
199 _meta: { ui: { resourceUri: DICE_UI } }, // tells the host which resource renders this tool
200 },
201 async ({ sides, count }) => {
202 const rolls = Array.from({ length: count }, () => 1 + Math.floor(Math.random() * sides));
203 const total = rolls.reduce((a, b) => a + b, 0);
204 return {
205 content: [{ type: 'text', text: `Rolled ${count}d${sides}: [${rolls.join(', ')}] total=${total}` }], // text-only hosts read this
206 structuredContent: { sides, count, rolls, total }, // the UI reads this
207 };
208 },
209 );
210 
211 registerAppResource(server, 'Dice view', DICE_UI, { description: 'Interactive view for roll_dice' }, async () => ({
212 contents: [{
213 uri: DICE_UI,
214 mimeType: RESOURCE_MIME_TYPE,
215 text: DICE_HTML,
216 _meta: { ui: { csp: { resourceDomains: ['https://unpkg.com'] } } }, // let the sandbox load the App script
217 }],
218 }));
219 return server;
220 }
221 
222 // Streamable HTTP: every MCP message arrives as a POST to /mcp. Stateless, so each request gets a fresh server.
223 const app = createMcpExpressApp(); // Express app with JSON parsing and localhost protection built in
224 app.post('/mcp', async (req, res) => {
225 const server = buildServer();
226 const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); // undefined = no sessions
227 res.on('close', () => { transport.close(); server.close(); });
228 await server.connect(transport);
229 await transport.handleRequest(req, res, req.body);
230 });
231 const notAllowed = (req, res) => res.status(405).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Method not allowed.' }, id: null });
232 app.get('/mcp', notAllowed);
233 app.delete('/mcp', notAllowed);
234 
235 const PORT = Number(process.env.PORT ?? 3000);
236 app.listen(PORT, '127.0.0.1', (err) => { // Express 5 reports a taken port here instead of throwing
237 if (err) { console.error(`failed to listen on ${PORT}: ${err.message}`); process.exit(1); }
238 console.log(`quickstart-server listening on http://localhost:${PORT}/mcp`);
239 });
240 ```
241</Accordion>
144> **server.mjs with the MCP App added**
145>
146> ```js server.mjs theme={null}
147> import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
148> import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
149> import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
150> import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
151> import { z } from 'zod';
152>
153> const DICE_UI = 'ui://quickstart/dice'; // MCP App resources use the ui:// scheme
154>
155> const DICE_HTML = `<!DOCTYPE html>
156> <html><head><meta charset="utf-8"><meta name="color-scheme" content="light dark"><title>Dice</title>
157> <style>
158> body { font: 14px system-ui, sans-serif; margin: 0; padding: 12px; }
159> #dice { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
160> .die { width: 44px; height: 44px; border: 2px solid; border-radius: 10px; display: grid; place-items: center; font-size: 18px; font-weight: 600; }
161> </style></head>
162> <body>
163> <div id="summary">Waiting for a roll…</div>
164> <div id="dice"></div>
165> <button id="again" disabled>Roll again</button>
166> <script type="module">
167> // The in-frame client. Loading the prebuilt bundle from a CDN means no bundler in this project.
168> import { App } from "https://unpkg.com/@modelcontextprotocol/[email protected]/dist/src/app-with-deps.js";
169> const $ = (id) => document.getElementById(id);
170> const app = new App({ name: "Dice View", version: "1.0.0" });
171> let lastArgs = { sides: 6, count: 1 };
172> const show = ({ structuredContent: sc, content }) => {
173> $("summary").textContent = sc ? sc.count + "d" + sc.sides + " → total " + sc.total : (content?.[0]?.text ?? "?");
174> $("dice").replaceChildren(...(sc?.rolls ?? []).map((n) => Object.assign(document.createElement("div"), { className: "die", textContent: n })));
175> $("again").disabled = false;
176> };
177> // Set handlers before connect() so the first tool-input and tool-result notifications aren't missed.
178> app.ontoolinput = ({ arguments: args }) => { if (args) lastArgs = { ...lastArgs, ...args }; };
179> app.ontoolresult = show; // the host pushes the tool result here; draw it
180> $("again").onclick = async () => show(await app.callServerTool({ name: "roll_dice", arguments: lastArgs }));
181> await app.connect(); // handshake with the host
182> </script>
183> </body></html>`;
184>
185> function buildServer() {
186> const server = new McpServer({ name: 'quickstart-server', version: '1.0.0' }); // the name Claude sees
187>
188> registerAppTool(
189> server,
190> 'roll_dice',
191> {
192> title: 'Roll dice',
193> description: 'Roll `count` dice with `sides` sides each. Returns each roll and the total.',
194> inputSchema: {
195> sides: z.number().int().min(2).max(100).describe('Number of sides per die (2-100)'),
196> count: z.number().int().min(1).max(10).default(1).describe('How many dice to roll (1-10)'),
197> },
198> _meta: { ui: { resourceUri: DICE_UI } }, // tells the host which resource renders this tool
199> },
200> async ({ sides, count }) => {
201> const rolls = Array.from({ length: count }, () => 1 + Math.floor(Math.random() * sides));
202> const total = rolls.reduce((a, b) => a + b, 0);
203> return {
204> content: [{ type: 'text', text: `Rolled ${count}d${sides}: [${rolls.join(', ')}] total=${total}` }], // text-only hosts read this
205> structuredContent: { sides, count, rolls, total }, // the UI reads this
206> };
207> },
208> );
209>
210> registerAppResource(server, 'Dice view', DICE_UI, { description: 'Interactive view for roll_dice' }, async () => ({
211> contents: [{
212> uri: DICE_UI,
213> mimeType: RESOURCE_MIME_TYPE,
214> text: DICE_HTML,
215> _meta: { ui: { csp: { resourceDomains: ['https://unpkg.com'] } } }, // let the sandbox load the App script
216> }],
217> }));
218> return server;
219> }
220>
221> // Streamable HTTP: every MCP message arrives as a POST to /mcp. Stateless, so each request gets a fresh server.
222> const app = createMcpExpressApp(); // Express app with JSON parsing and localhost protection built in
223> app.post('/mcp', async (req, res) => {
224> const server = buildServer();
225> const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); // undefined = no sessions
226> res.on('close', () => { transport.close(); server.close(); });
227> await server.connect(transport);
228> await transport.handleRequest(req, res, req.body);
229> });
230> const notAllowed = (req, res) => res.status(405).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Method not allowed.' }, id: null });
231> app.get('/mcp', notAllowed);
232> app.delete('/mcp', notAllowed);
233>
234> const PORT = Number(process.env.PORT ?? 3000);
235> app.listen(PORT, '127.0.0.1', (err) => { // Express 5 reports a taken port here instead of throwing
236> if (err) { console.error(`failed to listen on ${PORT}: ${err.message}`); process.exit(1); }
237> console.log(`quickstart-server listening on http://localhost:${PORT}/mcp`);
238> });
239> ```
242240 
243241## Check that the server advertises the UI
244242 
245243A host discovers the UI from the `_meta` on the tool in `tools/list` and loads it from `resources/read`, and your server now returns both. You can see both with `curl` before any host is involved.
246244 
247<Steps>
248 <Step title="Restart the server">
249 In the terminal where the server runs, press `Ctrl+C` to stop it, then start it again from the `mcp-quickstart` folder so it picks up your edits:
250 
251 ```bash theme={null}
252 node server.mjs
253 ```
254 
255 It prints `quickstart-server listening on http://localhost:3000/mcp` as before.
256 </Step>
257 
258 <Step title="Check the tool points at the UI">
259 In your second terminal, list the tools again:
260 
261 ```bash theme={null}
262 curl -sS -X POST http://localhost:3000/mcp \
263 -H 'Content-Type: application/json' \
264 -H 'Accept: application/json, text/event-stream' \
265 -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
266 ```
267 
268 The `roll_dice` entry now ends with a `_meta` object naming the resource. The helper also writes the same URI under the legacy flat key `ui/resourceUri`. The rest of the entry is shortened to `...` here:
269 
270 ```text theme={null}
271 data: {"result":{"tools":[{"name":"roll_dice", ... ,"_meta":{"ui":{"resourceUri":"ui://quickstart/dice"},"ui/resourceUri":"ui://quickstart/dice"}}]},"jsonrpc":"2.0","id":2}
272 ```
273 </Step>
274 
275 <Step title="Read the UI resource">
276 Fetch the resource the way a host does, by its `ui://` URI:
277 
278 ```bash theme={null}
279 curl -sS -X POST http://localhost:3000/mcp \
280 -H 'Content-Type: application/json' \
281 -H 'Accept: application/json, text/event-stream' \
282 -d '{"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"ui://quickstart/dice"}}'
283 ```
284 
285 The reply carries the MCP App MIME type and your HTML as `text`, shortened here after the opening tags:
286 
287 ```text theme={null}
288 data: {"result":{"contents":[{"uri":"ui://quickstart/dice","mimeType":"text/html;profile=mcp-app","text":"<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\"> ...
289 ```
290 </Step>
291 
292 <Step title="Call the tool and see the structured result">
293 Call `roll_dice` once more:
294 
295 ```bash theme={null}
296 curl -sS -X POST http://localhost:3000/mcp \
297 -H 'Content-Type: application/json' \
298 -H 'Accept: application/json, text/event-stream' \
299 -d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"roll_dice","arguments":{"sides":6,"count":4}}}'
300 ```
301 
302 The result has both the text block and the `structuredContent` object the UI draws from:
303 
304 ```text theme={null}
305 data: {"result":{"content":[{"type":"text","text":"Rolled 4d6: [1, 3, 1, 2] total=7"}],"structuredContent":{"sides":6,"count":4,"rolls":[1,3,1,2],"total":7}},"jsonrpc":"2.0","id":5}
306 ```
307 </Step>
308</Steps>
245**1. Restart the server**
246 
247In the terminal where the server runs, press `Ctrl+C` to stop it, then start it again from the `mcp-quickstart` folder so it picks up your edits:
248 
249```bash theme={null}
250node server.mjs
251```
252 
253It prints `quickstart-server listening on http://localhost:3000/mcp` as before.
254 
255**2. Check the tool points at the UI**
256 
257In your second terminal, list the tools again:
258 
259```bash theme={null}
260curl -sS -X POST http://localhost:3000/mcp \
261 -H 'Content-Type: application/json' \
262 -H 'Accept: application/json, text/event-stream' \
263 -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
264```
265 
266The `roll_dice` entry now ends with a `_meta` object naming the resource. The helper also writes the same URI under the legacy flat key `ui/resourceUri`. The rest of the entry is shortened to `...` here:
267 
268```text theme={null}
269data: {"result":{"tools":[{"name":"roll_dice", ... ,"_meta":{"ui":{"resourceUri":"ui://quickstart/dice"},"ui/resourceUri":"ui://quickstart/dice"}}]},"jsonrpc":"2.0","id":2}
270```
271 
272**3. Read the UI resource**
273 
274Fetch the resource the way a host does, by its `ui://` URI:
275 
276```bash theme={null}
277curl -sS -X POST http://localhost:3000/mcp \
278 -H 'Content-Type: application/json' \
279 -H 'Accept: application/json, text/event-stream' \
280 -d '{"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"ui://quickstart/dice"}}'
281```
282 
283The reply carries the MCP App MIME type and your HTML as `text`, shortened here after the opening tags:
284 
285```text theme={null}
286data: {"result":{"contents":[{"uri":"ui://quickstart/dice","mimeType":"text/html;profile=mcp-app","text":"<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\"> ...
287```
288 
289**4. Call the tool and see the structured result**
290 
291Call `roll_dice` once more:
292 
293```bash theme={null}
294curl -sS -X POST http://localhost:3000/mcp \
295 -H 'Content-Type: application/json' \
296 -H 'Accept: application/json, text/event-stream' \
297 -d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"roll_dice","arguments":{"sides":6,"count":4}}}'
298```
299 
300The result has both the text block and the `structuredContent` object the UI draws from:
301 
302```text theme={null}
303data: {"result":{"content":[{"type":"text","text":"Rolled 4d6: [1, 3, 1, 2] total=7"}],"structuredContent":{"sides":6,"count":4,"rolls":[1,3,1,2],"total":7}},"jsonrpc":"2.0","id":5}
304```
309305 
310306Claude Code still works with the server after these edits, as a text-only client: `claude mcp list` shows `quickstart` connected, and the same `claude -p` prompt from the first quickstart calls the tool and reports the rolls. One difference shows up in the `stream-json` output: because the tool now returns `structuredContent`, the `tool_result` Claude receives is that JSON object as a string, such as `{"sides":20,"count":3,"rolls":[7,11,12],"total":30}`, rather than your `content` text.
311307 
312308 
Feedback