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

In this lesson you will

  1. 1The same factory over HTTP with createMcpHandler
  2. 2server/discover: what the server announces
  3. 3Required headers and the -32020 error when they are missing
  4. 4Host and Origin guards on localhost; old client still served

Goal: serve the same server over stateless Streamable HTTP and read server/discover and the Mcp-Method and Mcp-Name headers in a request made by hand.

01One request, from curl to the response

Click each step.

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

02What left the 2025 HTTP

2025 tutorials teach SSE as its own transport, an initialize at the start, and the Mcp-Session-Id header to tie the requests of a session together. In 2026-07-28 that is gone: no session, no initialize, and the GET/SSE used to receive notices became subscriptions/listen (official changelog, read on 10/19/2026).

Stateless, the server scales like any API: two instances behind a load balancer answer the same. Whatever needs to survive between requests is your data, kept by you.

03The kit's HTTP server

Same factory as lesson 2. It listens only on 127.0.0.1.

File · kit/http.mjs
// http.mjs — serves the classes server over Streamable HTTP, stateless, on 127.0.0.1.
// Each request builds a new McpServer (createMcpHandler): the protocol keeps no session.
// Your service's data (here, TURMAS in the module) stays yours: database, cache, pool live outside the factory.
// The Host and Origin guards block DNS rebinding (a malicious page talking to your localhost).
import { createServer as criarHttp } from 'node:http';
import { createMcpHandler } from '@modelcontextprotocol/server';
import { toNodeHandler, localhostHostValidation, localhostOriginValidation } from '@modelcontextprotocol/node';
import { createServer } from './servidor.mjs';

const PORTA = Number(process.env.PORTA ?? 3333);
const handler = createMcpHandler(createServer);
const node = toNodeHandler(handler);
const hostOk = localhostHostValidation();
const origemOk = localhostOriginValidation();

const http = criarHttp((req, res) => {
  if (!hostOk(req, res) || !origemOk(req, res)) return; // replies 403 on its own
  if (!req.url.startsWith('/mcp')) { res.writeHead(404).end(); return; }
  void node(req, res);
});
http.listen(PORTA, '127.0.0.1', () => console.error(`turmas: MCP em http://127.0.0.1:${PORTA}/mcp`));

process.on('SIGINT', async () => { await handler.close(); http.close(); process.exit(0); });
process.on('SIGTERM', async () => { await handler.close(); http.close(); process.exit(0); });

04Ask the server what it offers

server/discover done by hand. The block starts the server, asks, and shuts it down.

Terminal · server/discover
cd ~/projetos/formacao-webmcp-mcp-v72/kit
PORTA=3333 node http.mjs & PID=$!; sleep 1
curl -s -X POST http://127.0.0.1:3333/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'Mcp-Method: server/discover' \
  -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{
       "io.modelcontextprotocol/protocolVersion":"2026-07-28",
       "io.modelcontextprotocol/clientInfo":{"name":"curl","version":"1.0.0"},
       "io.modelcontextprotocol/clientCapabilities":{}}}}' | grep '"supportedVersions"'
S=$?; kill $PID; wait $PID 2>/dev/null; echo "saída: $S"
Example from the repository
turmas: MCP em http://127.0.0.1:3333/mcp
{"result":{"supportedVersions":["2026-07-28"],"capabilities":{"tools":{"listChanged":true},"resources":{"listChanged":true},"prompts":{"listChanged":true}},"resultType":"complete","ttlMs":0,"cacheScope":"private","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"turmas","version":"1.0.0"}}},"jsonrpc":"2.0","id":1}
saída: 0

supportedVersions lists the revisions it speaks; capabilities, what it offers. ttlMs and cacheScope are the cache hints of the new revision.

05Call a tool, with and without Mcp-Name

The second request is the same, without the header. The server refuses it because the header and the body don't match.

Terminal · tools/call by hand
cd ~/projetos/formacao-webmcp-mcp-v72/kit
PORTA=3333 node http.mjs & PID=$!; sleep 1
META='"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"1.0.0"},"io.modelcontextprotocol/clientCapabilities":{}}'
chamar() {  # $1 = value of the Mcp-Name header (empty = without the header)
  curl -s -w ' [HTTP %{http_code}]\n' -X POST http://127.0.0.1:3333/mcp \
    -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
    -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: tools/call' ${1:+-H "Mcp-Name: $1"} \
    -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"buscar_turmas","arguments":{"termo":"mcp"},'"$META"'}}'
}
echo "## com Mcp-Name"; chamar buscar_turmas
echo "## sem Mcp-Name"; chamar ""
kill $PID; wait $PID 2>/dev/null
Example from the repository
turmas: MCP em http://127.0.0.1:3333/mcp
## com Mcp-Name
{"result":{"content":[{"type":"text","text":"mcp-01 · Servidor MCP em uma tarde · 1 vagas\nmcp-02 · MCP com autorização · 0 vagas"}],"structuredContent":{"turmas":[{"id":"mcp-01","titulo":"Servidor MCP em uma tarde","vagas":1},{"id":"mcp-02","titulo":"MCP com autorização","vagas":0}]},"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"turmas","version":"1.0.0"}}},"jsonrpc":"2.0","id":2} [HTTP 200]
## sem Mcp-Name
{"jsonrpc":"2.0","error":{"code":-32020,"message":"Bad Request: the request headers and body disagree: the body carries params.name=\"buscar_turmas\" but the required Mcp-Name header is absent","data":{"mismatch":{"header":"(missing)","body":"the body carries params.name=\"buscar_turmas\" but the required Mcp-Name header is absent"}}},"id":2} [HTTP 400]

A client built with the SDK sends all three headers on its own. The -32020 error shows up in a hand-written client or behind a proxy that strips headers.

06An old client is still served

An initialize request from the 2025-11-25 revision against the same server.

Terminal · 2025 client
cd ~/projetos/formacao-webmcp-mcp-v72/kit
PORTA=3333 node http.mjs & PID=$!; sleep 1
C=${TMPDIR:-/tmp}/cabecalhos-mcp.txt
# a client on the 2025-11-25 revision still opens with initialize; the same server answers
curl -s -D "$C" -X POST http://127.0.0.1:3333/mcp \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"antigo","version":"1.0.0"}}}' \
  | grep -o '"protocolVersion":"[^"]*"'
echo "Mcp-Session-Id na resposta: $(grep -ci '^mcp-session-id' "$C")"
rm -f "$C"; kill $PID; wait $PID 2>/dev/null
Example from the repository
turmas: MCP em http://127.0.0.1:3333/mcp
"protocolVersion":"2025-11-25"
Mcp-Session-Id na resposta: 0

The SDK v2 handler serves the stateless 2025 era and does not return Mcp-Session-Id. You don't need two servers during the transition.

07Practice

Three requests by hand and one tweak of your own.

E9 · discover

5 min

Run the server/discover block.

Done when: the output has "supportedVersions":["2026-07-28"] and ends with saída: 0. Got saída: 1? Check that port 3333 is free (ss -ltn | grep 3333).

E10 · The header error

5 min

Run the tools/call by hand block.

Done when: first call [HTTP 200] with "structuredContent"; second [HTTP 400] with "code":-32020.

E11 · Another port

5 min

Start the server with PORTA=4000 node http.mjs and repeat the discover, changing the port in curl.

Done when: curl at http://127.0.0.1:4000/mcp returns "supportedVersions".

08Lesson quiz

Question 1 of 4

In the 2026-07-28 revision, how does the server know the client's version and capabilities?skill

09In three sentences

StatelessEach request carries _meta; no session and no initialize.
HeadersMCP-Protocol-Version, Mcp-Method and Mcp-Name mirror the body.
LocalhostHost and Origin guards in front of the handler.

Going further

Previous lessonNext lesson