Servidor MCP do seu serviço|INEMA.CLUB-PRO

Nesta aula você vai

  1. 1Ferramenta + recurso ui:// + iframe isolado no cliente
  2. 2registerAppTool e registerAppResource do pacote ext-apps 2.0.3
  3. 3Conferir com o Inspector --app-info
  4. 4Cliente sem suporte recebe o texto; CSP restringe a rede da página

Objetivo: ligar uma interface HTML a uma ferramenta com MCP Apps e conferir o vínculo pelo Inspector, sem abrir nenhum cliente.

01Como o painel chega à conversa

Clique em cada etapa.

Clique em cada bloco do diagrama para ver o que ele faz.

MCP Apps é a primeira extensão oficial do MCP (io.modelcontextprotocol/ui), anunciada em 26/01/2026 com suporte no Claude (web e desktop), Goose e VS Code Insiders.

02O servidor com painel

app.mjs reaproveita o servidor de turmas e acrescenta uma ferramenta com interface.

Arquivo · kit/app.mjs
// app.mjs — o servidor de turmas + um MCP App (extensão oficial io.modelcontextprotocol/ui):
// a ferramenta ver_turmas aponta para a interface ui://turmas/painel.html, que o cliente
// desenha num iframe isolado. Cliente sem suporte a MCP Apps recebe o texto normal.
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';
// A classe App vem pronta no pacote (app-with-deps.js); a View é um HTML só, sem build.
// Para caber num <script> só, trocamos o "export {… X as App …}" final por "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); // função: o bundle tem "$&" que o replace leria como padrão

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');
}
Arquivo · 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*/
  // A View conversa com o cliente (host) por postMessage; a classe App cuida do aperto de mão.
  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>

03Um atalho de demonstração, dito como atalho

Para a página ficar num HTML só, sem build, o kit embute o app-with-deps.js do pacote e troca o export final por uma constante. Funciona com o ext-apps 2.0.3 que o kit fixa; numa versão nova pode quebrar.

Em projeto de verdade, siga o quickstart oficial do MCP Apps (página com bundler, ou os modelos prontos do repositório ext-apps).

04Confira o vínculo pelo Inspector

--app-info lê o metadado de interface de cada ferramenta sem chamar nenhuma.

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: $?"
Exemplo do repositório
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

Só ver_turmas tem interface. Para ver o painel desenhado, ligue o servidor a um cliente com suporte a MCP Apps; cliente sem suporte mostra só o texto do resultado.

05Prática

Conferir e mudar o painel.

E18 · O vínculo

5 min

Rode o bloco app-info.

Pronto quando: a saída tem "hasApp":true,"toolName":"ver_turmas" e "resourceMimeType":"text/html;profile=mcp-app", e saída: 0.

E19 · Sem vínculo

5 min

Troque resourceUri por ui://turmas/outro.html no app.mjs, rode de novo e depois desfaça.

Pronto quando: a linha de ver_turmas passa a mostrar "resourceError":"Resource not found: ui://turmas/outro.html"; desfeito, volta "resourceMimeType":"text/html;profile=mcp-app".

E20 · Seu painel

15 min

Mude o painel.html para mostrar só turmas com vaga (filtre a lista com filter() e rode o bloco app-info de novo.

Pronto quando: grep -c "filter(" painel.html → 1 ou mais (antes da mudança: 0), e o bloco app-info continua com "hasApp":true.

06Teste da aula

Pergunta 1 de 4

Como uma ferramenta diz ao cliente que tem interface?competência

07Em três frases

Vínculo_meta.ui.resourceUri na ferramenta; HTML como recurso ui://.
ClienteDesenha num iframe isolado; sem suporte, só texto.
ConferirInspector --app-info → hasApp:true.

Para ir além

Aula anteriorPróxima aula