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

In this lesson you will

  1. 1Install the kit with npm ci and check the versions
  2. 2The createServer factory and the SDK v2 serveStdio
  3. 3Call the tool through Inspector 2.11.0 in --cli mode
  4. 4Why every log goes to stderr

Goal: start an MCP server over stdio with the official v2 SDK and call it with a test client and with the Inspector, no host at all.

01From npm ci to the first call

Click each step.

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

02Install the kit

Once. You need Node 20 or newer and git. The blocks in the next lessons start from this folder.

Terminal · install
git clone https://github.com/inematds/formacao-webmcp-mcp-v72 ~/projetos/formacao-webmcp-mcp-v72
cd ~/projetos/formacao-webmcp-mcp-v72/kit
npm ci                      # installs the versions from package-lock.json
npm ls --depth=0
Example from the repository
Cloning into '~/projetos/formacao-webmcp-mcp-v72'...

added 19 packages, and audited 20 packages in 3s

2 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
kit-servidor-mcp@1.0.0 ~/projetos/formacao-webmcp-mcp-v72/kit
├── @modelcontextprotocol/client@2.3.1
├── @modelcontextprotocol/ext-apps@2.0.3
├── @modelcontextprotocol/node@2.1.1
├── @modelcontextprotocol/server@2.3.1
└── zod@4.6.7

In the capture, the home folder was replaced with ~; in your terminal the full path appears.

03The classes server

Read before you run. The two tools, the resource, and the prompt are in the same factory; stdio is just the front door.

File · kit/servidor.mjs
// servidor.mjs — the course's example MCP server (a fictional class service).
// Official SDK v2 (@modelcontextprotocol/server 2.3.1, from 05/10/2026), which serves the
// 2026-07-28 MCP revision and, with the same code, clients on the 2025-11-25 revision.
// It calls no model or paid service: the data stays in the process memory.
import { McpServer, acceptedContent, inputRequired, inputResponse, requireScopes } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

export const TURMAS = [
  { id: 'html-01', titulo: 'HTML semântico para agentes', vagas: 3 },
  { id: 'mcp-01', titulo: 'Servidor MCP em uma tarde', vagas: 1 },
  { id: 'mcp-02', titulo: 'MCP com autorização', vagas: 0 }
];

const Turma = z.object({ id: z.string(), titulo: z.string(), vagas: z.int() });
const Confirma = z.object({ confirmar: z.boolean().meta({ title: 'Confirmar a reserva' }) });

export function createServer() {
  const server = new McpServer({ name: 'turmas', version: '1.0.0' });

  // 1) Read-only tool: the client can approve it without asking.
  server.registerTool(
    'buscar_turmas',
    {
      title: 'Buscar turmas',
      description: 'Lista turmas cujo título contém o termo. Só leitura: não reserva nada.',
      inputSchema: z.object({
        termo: z.string().min(2).describe('Parte do título, ex.: "mcp"'),
        limite: z.int().min(1).max(20).optional().describe('Máximo de turmas (padrão 5)')
      }),
      outputSchema: z.object({ turmas: z.array(Turma) }),
      annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }
    },
    async ({ termo, limite }) => {
      const t = termo.toLowerCase();
      const turmas = TURMAS.filter(x => x.titulo.toLowerCase().includes(t)).slice(0, limite ?? 5);
      return {
        content: [{ type: 'text', text: turmas.map(x => `${x.id} · ${x.titulo} · ${x.vagas} vagas`).join('\n') || 'nenhuma turma' }],
        structuredContent: { turmas }
      };
    }
  );

  // 2) Tool with consequences: asks the user for confirmation mid-call (MRTR).
  server.registerTool(
    'reservar_vaga',
    {
      title: 'Reservar vaga',
      description: 'Reserva uma vaga na turma para o e-mail informado. Tem consequência: pede confirmação antes.',
      inputSchema: z.object({
        turma_id: z.string().describe('Id da turma, ex.: "mcp-01"'),
        email: z.email().describe('E-mail de quem vai fazer a turma')
      }),
      annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
      // With a token (http-auth.mjs), it requires the turmas:reservar scope; without a token (stdio), it does not apply.
      scopeChallenge: requireScopes('turmas:reservar')
    },
    async ({ turma_id, email }, ctx) => {
      const turma = TURMAS.find(x => x.id === turma_id);
      if (!turma) return { content: [{ type: 'text', text: `turma ${turma_id} não existe` }], isError: true };
      if (turma.vagas < 1) return { content: [{ type: 'text', text: `turma ${turma_id} sem vagas` }], isError: true };

      const resposta = inputResponse(ctx.mcpReq.inputResponses, 'confirmar');
      if (resposta.kind === 'elicit' && resposta.action !== 'accept') {
        return { content: [{ type: 'text', text: 'reserva cancelada pelo usuário' }], isError: true };
      }
      const ok = acceptedContent(ctx.mcpReq.inputResponses, 'confirmar', Confirma);
      if (ok?.confirmar !== true) {
        return inputRequired({
          inputRequests: {
            confirmar: inputRequired.elicit({ message: `Reservar 1 vaga em "${turma.titulo}" para ${email}?`, requestedSchema: Confirma })
          }
        });
      }
      turma.vagas -= 1;
      return { content: [{ type: 'text', text: `reservado: ${turma_id} para ${email} (restam ${turma.vagas})` }] };
    }
  );

  // 3) Resource: data the client reads whenever it wants (not an action).
  server.registerResource(
    'catalogo',
    'turmas://catalogo',
    { title: 'Catálogo de turmas', description: 'Todas as turmas em JSON', mimeType: 'application/json' },
    async uri => ({ contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(TURMAS) }] })
  );

  // 4) Prompt: a request template the user picks in the client.
  server.registerPrompt(
    'convite_turma',
    {
      title: 'Convite para a turma',
      description: 'Monta o pedido de um convite curto para uma turma',
      argsSchema: z.object({ turma_id: z.string() })
    },
    async ({ turma_id }) => ({
      messages: [{ role: 'user', content: { type: 'text', text: `Escreva um convite de 3 linhas para a turma ${turma_id}, usando o recurso turmas://catalogo.` } }]
    })
  );

  return server;
}
File · kit/stdio.mjs
// stdio.mjs — serves the classes server over stdio (the client starts this process).
// stdout is the protocol channel: every log goes to stderr (console.error).
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { createServer } from './servidor.mjs';

serveStdio(createServer);
console.error('turmas: servidor MCP no stdio');

04Call it through the test client

The test plays the role of Claude Code: it starts node stdio.mjs, negotiates the revision, and calls buscar_turmas.

Terminal · stdio test
cd ~/projetos/formacao-webmcp-mcp-v72/kit
node teste-stdio.mjs; echo "saída: $?"
File · kit/teste-stdio.mjs
// Usage: node teste-stdio.mjs [server file, default stdio.mjs]
// teste-stdio.mjs — plays the role of the client (Claude Code, Codex…): starts "node stdio.mjs",
// negotiates the protocol revision, and calls a tool through the process's stdin/stdout.
import { Client } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';

const cliente = new Client({ name: 'teste-stdio', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await cliente.connect(new StdioClientTransport({ command: 'node', args: [process.argv[2] ?? 'stdio.mjs'] }));
const { tools } = await cliente.listTools();
const r = await cliente.callTool({ name: 'buscar_turmas', arguments: { termo: 'mcp' } });
console.log(`era: ${cliente.getProtocolEra()} · ferramentas: ${tools.map(t => t.name).join(', ')}`);
console.log(`buscar_turmas → ${r.structuredContent.turmas.length} turmas`);
await cliente.close();
Terminal · common error (without npm ci)
cd "$(mktemp -d)" && cp ~/projetos/formacao-webmcp-mcp-v72/kit/*.mjs ~/projetos/formacao-webmcp-mcp-v72/kit/package.json .
node teste-stdio.mjs 2>&1 | grep -m1 -o "ERR_MODULE_NOT_FOUND\|Cannot find package '[^']*'"; echo "saída: ${PIPESTATUS[0]}"
Example from the repository
turmas: servidor MCP no stdio
era: modern · ferramentas: buscar_turmas, reservar_vaga
buscar_turmas → 2 turmas
saída: 0

The first line came from the server's stderr; the rest came from the client.

05Call it through the MCP Inspector, without opening a browser

Inspector 2.11.0 has a command-line mode. The version is pinned in npx so the result matches the capture.

Terminal · Inspector --cli
cd ~/projetos/formacao-webmcp-mcp-v72/kit
npx -y @modelcontextprotocol/inspector@2.11.0 --cli node stdio.mjs \
  --method tools/call --tool-name buscar_turmas --tool-arg termo=html
echo "saída: $?"
Example from the repository
turmas: servidor MCP no stdio
{
  "content": [
    {
      "type": "text",
      "text": "html-01 · HTML semântico para agentes · 3 vagas"
    }
  ],
  "structuredContent": {
    "turmas": [
      {
        "id": "html-01",
        "titulo": "HTML semântico para agentes",
        "vagas": 3
      }
    ]
  }
}
saída: 0

Without --cli, the same command opens the Inspector web interface, where you connect and test the tools by clicking.

06stdout belongs to the protocol

On stdio, every line of stdout is read as a JSON-RPC message. That's why the kit logs with console.error (stderr). The SDK documentation warns that a console.log corrupts the channel and the host may drop the connection.

In our test on 10/19/2026, the SDK 2.3.1 client and Inspector 2.11.0 skipped a stray line and kept going. Don't count on that: another host may not be so forgiving. Always log to stderr.

Prefer Python? The official SDK package mcp 2.3.0 (PyPI, downloaded on 10/19/2026) contains server/discover and InputRequiredResult in its code; check the python-sdk release notes before using it. This course stays with Node; the concepts apply equally.

07Practice

Rehearsal on the kit and one change of your own.

E3 · Kit installed

5 min

Run the install block.

Done when: npm ls --depth=0 → shows @modelcontextprotocol/server@2.3.1 and @modelcontextprotocol/client@2.3.1.

E4 · First call

5 min

Run the stdio test and the Inspector --cli.

Done when: node teste-stdio.mjs → era: modern · ferramentas: buscar_turmas, reservar_vaga and saída: 0; the Inspector returns a JSON with "structuredContent".

E5 · A class of your own

10 min

Add a class to TURMAS in servidor.mjs with the word mcp in the title and run the test again.

Done when: node teste-stdio.mjs → buscar_turmas → 3 turmas.

08Lesson quiz

Question 1 of 4

On the stdio transport, who starts the server process?skill

09In three sentences

Versionnpm ci with the kit's lock: SDK v2 2.3.1.
ServerOne createServer factory; serveStdio is the door.
TestSDK client and Inspector --cli, no host and no cost.

Going further

Previous lessonNext lesson