Tutorial de servidor MCP em TypeScript: exemplo do SDK e template para copiar
Um servidor MCP funcionando em TypeScript, do npm install ao Claude Code. Copie o template do SDK, registre uma ferramenta, um recurso e um prompt, escolha stdio ou Streamable HTTP, teste no Inspector e depois adicione ferramentas de imagem e vídeo que chamam uma API real sem bloquear o cliente.
Você pode conectar um modelo de linguagem ao seu próprio código em cerca de quarenta linhas. Este tutorial de servidor MCP em TypeScript faz exatamente isso: um servidor funcionando com o SDK oficial, um template de projeto para copiar e os dois transportes que importam, stdio para clientes locais e Streamable HTTP para clientes remotos. Você terminará com uma ferramenta, um recurso e um prompt, testados no Inspector e registrados no Claude Code. Depois, adicionamos ferramentas de imagem e vídeo, porque é aí que um servidor Model Context Protocol deixa de ser uma demonstração e passa a fazer trabalho de verdade. Todos os trechos de código rodam no Node.js 20 ou mais recente com o pacote @modelcontextprotocol/sdk.
O que um servidor MCP faz
O Model Context Protocol (MCP) é um padrão aberto que permite a um cliente de IA, como o Claude Code, o Claude Desktop ou um agente de IDE, chamar funções e ler dados que vivem dentro do seu processo. As mensagens trafegam como JSON-RPC 2.0. Seu servidor anuncia o que oferece, o cliente lista essas capacidades e o modelo decide quando usá-las. Seu código nunca conversa diretamente com o modelo. Ele responde às solicitações, e é por isso que o servidor continua pequeno.
Três blocos de construção
Todo servidor MCP é feito de alguma combinação de três primitivas:
Bloco
Quem decide usar
Uso típico
Ferramenta
O modelo
Consultar um banco de dados, chamar uma API, gerar uma imagem
Recurso
A aplicação ou o usuário
Expor um documento, um arquivo ou uma configuração como contexto legível
Prompt
O usuário
Um template reutilizável, como "revise este pull request"
Na prática, as ferramentas fazem a maior parte do trabalho. Uma ferramenta é uma função com nome, um esquema de entrada tipado e um resultado de texto ou imagem. Recursos e prompts são opcionais, mas custam quase nada para adicionar depois que o servidor existe.
Cliente, servidor e transporte
O transporte é apenas o canal por onde as mensagens JSON-RPC passam. O mesmo objeto McpServer funciona com stdio ou HTTP, então a estrutura certa é construir o servidor em uma única função e conectar um transporte em um arquivo de entrada separado. O template abaixo segue essa regra, o que mantém os testes simples e permite publicar os dois transportes a partir de uma única base de código.
Configurar o projeto TypeScript
Instalar o SDK e o zod
Crie uma pasta e instale as dependências. O SDK usa o zod para os esquemas de entrada: ele os converte em JSON Schema para o cliente e valida cada argumento recebido antes do seu handler rodar.
mcp-notes-server/
src/
index.ts stdio transport and startup
http.ts Streamable HTTP transport
server.ts buildServer() factory
package.json
tsconfig.json
💡 Os imports do SDK terminam com .js, mesmo em arquivos TypeScript. Com a resolução Node16, o compilador exige extensões explícitas, e uma extensão faltando aparece em tempo de execução como ERR_MODULE_NOT_FOUND.
Construir o template do servidor
A fábrica do servidor
Coloque tudo o que o servidor oferece em src/server.ts. Esta versão registra uma ferramenta que salva uma nota e outra que busca uma nota:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
const notes = new Map<string, string>();
export function buildServer(): McpServer {
const server = new McpServer({ name: "notes-server", version: "1.0.0" });
server.registerTool(
"add_note",
{
title: "Add note",
description: "Save a short note under a unique id. Overwrites an existing id.",
inputSchema: {
id: z.string().min(1).describe("Unique id, for example 'standup-0612'"),
text: z.string().min(1).max(2000).describe("The note body"),
},
},
async ({ id, text }) => {
notes.set(id, text);
return { content: [{ type: "text", text: `Saved note ${id}` }] };
}
);
server.registerTool(
"get_note",
{
title: "Get note",
description: "Return the text of a saved note by id.",
inputSchema: { id: z.string().min(1).describe("The note id") },
},
async ({ id }) => {
const text = notes.get(id);
if (text === undefined) {
return { isError: true, content: [{ type: "text", text: `No note with id ${id}` }] };
}
return { content: [{ type: "text", text }] };
}
);
// resources and prompts go here (next section)
return server;
}
Dois detalhes importam mais do que parecem. Primeiro, a descrição é o que o modelo lê ao decidir se chama a ferramenta, então escreva-a como documentação para um colega. Segundo, quando algo falha, retorne isError: true com uma mensagem legível em vez de lançar uma exceção. O modelo pode então tentar de novo com um argumento corrigido ou explicar o problema ao usuário.
Adicionar um recurso e um prompt
Substitua o comentário de placeholder por este código:
server.registerResource(
"all-notes",
"notes://all",
{
title: "All notes",
description: "Every saved note as JSON",
mimeType: "application/json",
},
async (uri) => ({
contents: [{ uri: uri.href, text: JSON.stringify([...notes.entries()]) }],
})
);
server.registerPrompt(
"summarize-notes",
{
title: "Summarize notes",
description: "Ask for a short summary of the saved notes",
argsSchema: { tone: z.string().optional() },
},
({ tone }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `Summarize my saved notes in a ${tone ?? "neutral"} tone.`,
},
},
],
})
);
Os recursos são endereçados por URI (notes://all), e os clientes normalmente os mostram em um seletor para que o usuário os anexe como contexto. Os prompts aparecem como comandos de barra ou itens de menu, dependendo do cliente.
Deixe um modelo de código ajudar
Depois que este template roda, um modelo de código pode adicionar ferramentas em minutos. Cole src/server.ts em um chat e peça uma nova ferramenta que siga o mesmo padrão: esquema primeiro, isError em caso de falha. Claude Sonnet 5, Kimi K2.6 e GPT 5.6 Sol são todos feitos para trabalho com código e disponíveis no PicassoIA. Leia o esquema gerado antes de aceitá-lo: um modelo faz campos obrigatórios virarem opcionais sem a menor cerimônia.
Escolher um transporte
stdio para clientes locais
O stdio é o transporte mais simples. O cliente inicia seu servidor como um processo filho e se comunica com ele por stdin e stdout. Crie src/index.ts:
#!/usr/bin/env node
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { buildServer } from "./server.js";
const server = buildServer();
await server.connect(new StdioServerTransport());
console.error("notes-server ready on stdio");
💡 Nunca use console.log em um servidor stdio. A saída padrão transporta o protocolo, então uma linha de log solta corrompe o fluxo e o cliente se desconecta com um erro de parsing. Envie os logs para stderr com console.error.
Streamable HTTP para clientes remotos
Para um servidor que roda em um host e não em um notebook, use o Streamable HTTP. Ele substituiu o antigo transporte HTTP mais SSE e precisa de um único endpoint. Instale o Express com npm install express e npm install -D @types/express, depois salve isto como src/http.ts:
import express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { buildServer } from "./server.js";
const app = express();
app.use(express.json());
app.post("/mcp", async (req, res) => {
const server = buildServer();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on("close", () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(3000, () => console.error("MCP endpoint on http://localhost:3000/mcp"));
Definir sessionIdGenerator: undefined faz o transporte rodar no modo stateless: um servidor novo por requisição, sem sessão para acompanhar e escala horizontal como qualquer outra API. Se você precisar de notificações iniciadas pelo servidor ou de streams retomáveis, mude para o modo stateful com ids de sessão.
stdio
Streamable HTTP
Roda onde
Processo filho do cliente
Qualquer host acessível por HTTP
Autenticação
Herda o ambiente do usuário
Você a adiciona (OAuth ou tokens bearer)
Melhor para
Ferramentas pessoais e locais para desenvolvedores
Servidores compartilhados e hospedados
Escala
Um processo por cliente
Stateless, escala como uma API
Testar antes de conectar
Rodar o MCP Inspector
O Inspector é a interface oficial de depuração. Faça o build do projeto e inicie seu servidor por ele:
npm run build
npx @modelcontextprotocol/inspector node dist/index.js
Abra a URL local que ele mostrar, clique em Connect e use a aba Tools para listar as ferramentas e executar add_note com um argumento JSON. O painel de histórico mostra o tráfego JSON-RPC bruto, que é a forma mais rápida de identificar um esquema que não corresponde ao que você pretendia.
Registrar no Claude Code e no Claude Desktop
O Claude Code registra um servidor local com um único comando, e um servidor HTTP com uma flag de transporte:
claude mcp add notes -- node /absolute/path/mcp-notes-server/dist/index.js
claude mcp add --transport http notes-remote http://localhost:3000/mcp
O Claude Desktop lê um arquivo JSON (claude_desktop_config.json):
💡 Use caminhos absolutos nos dois. Um caminho relativo é resolvido a partir do diretório de trabalho do cliente, não do seu, e a mensagem de erro raramente deixa isso claro.
Adicionar ferramentas de imagem e vídeo
Um servidor de notas comprova o padrão. Ferramentas de mídia mostram por que ele compensa: trabalhos lentos, saídas grandes e uma API externa com seus próprios limites. O PicassoIA expõe uma API de desenvolvedor no estilo Replicate em https://api.picassoia.com/v1, autenticada com um token Bearer que começa com pia_sk_. Quatro modelos ficam acessíveis pela API e pelo conector MCP: PicassoIA Image para texto para imagem, PicassoIA Image Editor Pro para edições, PicassoIA Video para vídeo e Seedance 2.5 Lite para vídeo com áudio. Os trabalhos são assíncronos: você cria uma predição, consulta o status e depois lê o resultado.
Encapsular um endpoint de imagem
Dois pequenos auxiliares atendem a todos os modelos, porque o formato da API é o mesmo:
const API = "https://api.picassoia.com/v1";
const headers = {
Authorization: `Bearer ${process.env.PICASSOIA_API_TOKEN}`,
"Content-Type": "application/json",
};
export async function createPrediction(model: string, input: Record<string, unknown>) {
const res = await fetch(`${API}/models/${model}/predictions`, {
method: "POST",
headers,
body: JSON.stringify({ input }),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`Create failed: HTTP ${res.status}`);
return (await res.json()) as { id: string };
}
export async function getPrediction(id: string) {
const res = await fetch(`${API}/predictions/${id}`, { headers });
if (!res.ok) throw new Error(`Status failed: HTTP ${res.status}`);
return (await res.json()) as { status: string; output?: unknown; error?: string };
}
A ferramenta de imagem usa esses auxiliares e espera até dois minutos por um resultado:
server.registerTool(
"generate_image",
{
title: "Generate image",
description: "Create an image from a text prompt and return its URL.",
inputSchema: { prompt: z.string().min(10).max(4000) },
},
async ({ prompt }) => {
try {
const { id } = await createPrediction("picassoia/picassoia-image", { prompt });
for (let i = 0; i < 60; i++) {
const job = await getPrediction(id);
if (job.status === "succeeded") {
return { content: [{ type: "text", text: JSON.stringify(job.output) }] };
}
if (job.status === "failed") throw new Error(job.error ?? "Generation failed");
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error("Timed out waiting for the image");
} catch (err) {
return { isError: true, content: [{ type: "text", text: String(err) }] };
}
}
);
O limite de 4.000 caracteres no esquema corresponde ao limite de prompt da API, e cada página de modelo lista os campos de entrada exatos e o formato de saída. A API também permite 5 predições simultâneas por conta, compartilhadas entre tokens e conexões MCP, então enfileire as chamadas de ferramenta em paralelo em vez de disparar todas de uma vez.
Lidar com trabalhos lentos de vídeo
O vídeo leva muito mais tempo que uma imagem, e uma chamada de ferramenta que bloqueia por minutos pode atingir o timeout próprio do cliente. Divida o trabalho em duas ferramentas: uma inicia o trabalho e devolve o id imediatamente, a outra consulta o status.
server.registerTool(
"start_video",
{
title: "Start video",
description: "Start a video job from a prompt. Returns a prediction id to check later.",
inputSchema: { prompt: z.string().min(10).max(4000) },
},
async ({ prompt }) => {
const { id } = await createPrediction("picassoia/picassoia-video", { prompt });
return { content: [{ type: "text", text: JSON.stringify({ predictionId: id }) }] };
}
);
server.registerTool(
"check_video",
{
title: "Check video",
description: "Return the status and output of a video job by prediction id.",
inputSchema: { predictionId: z.string().min(1) },
},
async ({ predictionId }) => {
const job = await getPrediction(predictionId);
return { content: [{ type: "text", text: JSON.stringify(job) }] };
}
);
O modelo chama start_video, faz outras coisas e consulta check_video até que o status diga succeeded. Nada bloqueia, e um trabalho que falhou é só mais um status para reportar. Para vídeo com áudio, troque o modelo para picassoia/seedance-2.5-lite (Seedance 2.5 Lite); os auxiliares não mudam.
Publicar com segurança
Validar entradas e proteger segredos
Trate cada argumento de ferramenta como não confiável. Um modelo pode ser direcionado por textos que lê na web ou em um arquivo, então uma página com instruções injetadas pode pedir à sua ferramenta que faça algo que você nunca pretendeu. Três hábitos reduzem a maior parte do risco:
Limite cada campo no zod: min, max, enum e regex para ids.
Nunca passe argumentos para um comando de shell ou para uma string SQL. Use consultas parametrizadas e restrinja os caminhos de arquivo a um único diretório base.
Leia os tokens a partir de variáveis de ambiente, nunca os escreva diretamente no código, e nunca os devolva em um resultado de ferramenta.
Para clientes stdio, defina os segredos no bloco env da configuração do cliente. Para servidores HTTP, exija um cabeçalho Authorization e verifique-o antes de handleRequest rodar.
Corrija os três erros mais comuns
console.log em stdio. Troque por console.error.
Extensões .js ausentes. Um import como ./server falha em tempo de execução sob Node16.
Descrições vagas. Uma ferramenta chamada run com a descrição "faz coisas" nunca é escolhida, ou é escolhida com os argumentos errados. Dê a ela um nome que diga a ação e explique quando usá-la.
Para publicar, mantenha a linha shebang no topo de src/index.ts, rode npm run build e depois npm publish. Qualquer pessoa pode registrá-lo com claude mcp add notes -- npx -y mcp-notes-server. Servidores HTTP são distribuídos como contêiner ou rodam em qualquer host Node.
Experimente no Picasso IA
Agora você tem um template que roda localmente, roda remotamente e pode chamar modelos de imagem e vídeo. A forma mais rápida de ver o que essas ferramentas devolvem é testar os modelos manualmente antes. Abra o PicassoIA Image e escreva um prompt tão específico quanto os que você enviaria a partir de generate_image: sujeito, lente, luz, cenário. Depois, experimente o PicassoIA Video ou o Seedance 2.5 Lite para animar a ideia, e anote qual formulação dá o resultado que você quer antes de fixar valores padrão no seu servidor. Escolha qualquer modelo do catálogo completo em picassoia.com/en/all-models e conecte-o com os mesmos dois auxiliares. Crie suas próprias imagens no Picasso IA hoje e deixe sua primeira chamada de ferramenta MCP entregar o resultado.