E18 · The link
5 minRun the app-info block.
Done when: the output has "hasApp":true,"toolName":"ver_turmas" and "resourceMimeType":"text/html;profile=mcp-app", and saída: 0.
Lesson 7 · 15 min
Sometimes text is not enough: a list with colors, a chart, a form. MCP Apps is the official extension that links a tool to an HTML page that the client draws in an isolated iframe. In this lesson you link a panel to the ver_turmas tool and check the link without needing to open a client.
Start the lesson
Goal: link an HTML interface to a tool with MCP Apps and check the link through the Inspector, without opening any client.
Click each step.
MCP Apps is the first official MCP extension (io.modelcontextprotocol/ui), announced on 01/26/2026 with support in Claude (web and desktop), Goose, and VS Code Insiders.
app.mjs reuses the class server and adds a tool with an interface.
// app.mjs — the classes server + an MCP App (official extension io.modelcontextprotocol/ui):
// the ver_turmas tool points to the ui://turmas/painel.html interface, which the client
// renders in an isolated iframe. A client without MCP Apps support gets the normal text.
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
import * as z from 'zod/v4';
import { createServer, TURMAS } from './servidor.mjs';
const URI = 'ui://turmas/painel.html';
// The App class comes ready-made in the package (app-with-deps.js); the View is a single HTML file, no build.
// To fit in a single <script>, we swap the final "export {… X as App …}" for "const App = X;".
const bruto = readFileSync(createRequire(import.meta.url).resolve('@modelcontextprotocol/ext-apps/app-with-deps'), 'utf8');
const exporta = bruto.match(/export\s*\{([^}]*)\};?\s*$/);
const nomeApp = exporta[1].match(/(\w+)\s+as\s+App\b/)[1];
const appJs = bruto.slice(0, exporta.index) + `\nconst App = ${nomeApp};\n`;
const html = readFileSync(new URL('./painel.html', import.meta.url), 'utf8').replace('/*APP_JS*/', () => appJs); // a function: the bundle has "$&", which replace would read as a pattern
export function createServerComApp() {
const server = createServer();
registerAppTool(server, 'ver_turmas', {
title: 'Ver turmas (painel)',
description: 'Mostra as turmas com vagas num painel. Só leitura.',
inputSchema: z.object({ termo: z.string().min(2) }),
outputSchema: z.object({ turmas: z.array(z.object({ id: z.string(), titulo: z.string(), vagas: z.int() })) }),
annotations: { readOnlyHint: true },
_meta: { ui: { resourceUri: URI } }
}, async ({ termo }) => {
const turmas = TURMAS.filter(t => t.titulo.toLowerCase().includes(termo.toLowerCase()));
return { content: [{ type: 'text', text: `${turmas.length} turmas` }], structuredContent: { turmas } };
});
registerAppResource(server, 'Painel de turmas', URI, { description: 'Lista visual das turmas' },
async () => ({ contents: [{ uri: URI, mimeType: RESOURCE_MIME_TYPE, text: html }] }));
return server;
}
if (import.meta.url === `file://${process.argv[1]}`) {
serveStdio(createServerComApp);
console.error('turmas + MCP App: servidor no stdio');
}<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title>Turmas</title>
<style>
body { font: 15px/1.4 system-ui, sans-serif; margin: 12px; color: #111; }
li { margin: 4px 0; }
.zero { color: #a00; }
</style>
</head>
<body>
<h3>Turmas encontradas</h3>
<ul id="lista"><li>aguardando o resultado da ferramenta…</li></ul>
<script type="module">
/*APP_JS*/
// The View talks to the client (host) via postMessage; the App class handles the handshake.
const app = new App({ name: 'painel-turmas', version: '1.0.0' }, {});
app.ontoolresult = ({ structuredContent }) => {
const lista = document.getElementById('lista');
lista.replaceChildren(...(structuredContent?.turmas ?? []).map(t => {
const li = document.createElement('li');
li.textContent = `${t.titulo} — ${t.vagas} vagas`;
if (t.vagas === 0) li.className = 'zero';
return li;
}));
};
await app.connect();
</script>
</body>
</html>To keep the page in a single HTML file, with no build, the kit embeds the package's app-with-deps.js and swaps the final export for a constant. It works with the ext-apps 2.0.3 that the kit pins; in a newer version it may break.
In a real project, follow the official MCP Apps quickstart (a page with a bundler, or the ready-made templates in the ext-apps repository).
--app-info reads each tool's interface metadata without calling any of them.
cd ~/projetos/formacao-webmcp-mcp-v72/kit npx -y @modelcontextprotocol/inspector@2.11.0 --cli node app.mjs --method tools/list --app-info echo "saída: $?"
turmas + MCP App: servidor no stdio
{"hasApp":false,"toolName":"buscar_turmas"}
{"hasApp":false,"toolName":"reservar_vaga"}
{"hasApp":true,"toolName":"ver_turmas","resourceUri":"ui://turmas/painel.html","resourceMimeType":"text/html;profile=mcp-app"}
saída: 0Only ver_turmas has an interface. To see the panel rendered, connect the server to a client that supports MCP Apps; a client without support shows only the text of the result.
Check and change the panel.
Run the app-info block.
Done when: the output has "hasApp":true,"toolName":"ver_turmas" and "resourceMimeType":"text/html;profile=mcp-app", and saída: 0.
Change resourceUri to ui://turmas/outro.html in app.mjs, run it again, and then undo it.
Done when: the ver_turmas line now shows "resourceError":"Resource not found: ui://turmas/outro.html"; once undone, "resourceMimeType":"text/html;profile=mcp-app" comes back.
Change painel.html to show only classes with open seats (filter the list with filter() and run the app-info block again.
Done when: grep -c "filter(" painel.html → 1 or more (before the change: 0), and the app-info block still shows "hasApp":true.
Question 1 of 4
How does a tool tell the client it has an interface?skill
Question 2 of 4
The client does not support MCP Apps. What happens with ver_turmas?skill
Question 3 of 4
Where does the panel run?
Question 4 of 4
Why does the kit say that embedding the bundle is a shortcut?
Pick an option. The explanation appears after you try.