An MCP Server for Your Service|INEMA.CLUB-PRO

In this lesson you will

  1. 1Tool + ui:// resource + isolated iframe in the client
  2. 2registerAppTool and registerAppResource from the ext-apps 2.0.3 package
  3. 3Check it with Inspector --app-info
  4. 4A client without support gets the text; CSP restricts the page's network

Goal: link an HTML interface to a tool with MCP Apps and check the link through the Inspector, without opening any client.

01How the panel reaches the chat

Click each step.

Click each block in the diagram to see what it does.

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.

02The server with a panel

app.mjs reuses the class server and adds a tool with an interface.

File · kit/app.mjs
// 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');
}
File · kit/painel.html
<!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>

03A demo shortcut, called what it is

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).

04Check the link with the Inspector

--app-info reads each tool's interface metadata without calling any of them.

Terminal · app-info
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: $?"
Example from the repository
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: 0

Only 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.

05Practice

Check and change the panel.

E18 · The link

5 min

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.

E19 · No link

5 min

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.

E20 · Your panel

15 min

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.

06Lesson quiz

Question 1 of 4

How does a tool tell the client it has an interface?skill

07In three sentences

Link_meta.ui.resourceUri on the tool; HTML as a ui:// resource.
ClientDraws in an isolated iframe; without support, text only.
CheckInspector --app-info → hasApp:true.

Going further

Previous lessonNext lesson