Criando um servidor MCP em TypeScript com a API de Star Wars
Paulo Roberto Bolsanello·22 Set 2026·15 min de leitura
Um modelo de linguagem sabe muita coisa sobre Star Wars, mas não consulta nada sozinho. Ele não abre uma API, não lê um banco de dados e não confere se a informação que tem na memória está certa. O Model Context Protocol (MCP) é o padrão que resolve essa parte: você escreve um programa pequeno, chamado servidor MCP, que expõe ferramentas, e qualquer aplicação compatível passa a oferecer essas ferramentas ao modelo. Neste artigo vamos construir um servidor MCP em TypeScript que consulta a SWAPI, a API pública de Star Wars, testar com o MCP Inspector e ligar o servidor a um agente de verdade.
O MCP foi aberto pela Anthropic em novembro de 2024 como um padrão para conectar assistentes de IA aos sistemas onde os dados estão. Antes dele, cada integração entre um modelo e uma fonte de dados exigia código próprio. Com um protocolo comum, o mesmo servidor funciona no Claude Code, no VS Code com Copilot, no Cursor e em qualquer outra aplicação que implemente o padrão.
A arquitetura tem três participantes. O host é a aplicação que tem o modelo dentro, como o editor ou o assistente. O cliente é o componente que o host cria para manter a conexão com um servidor, um cliente para cada servidor. O servidor é o programa que oferece contexto e ações. É ele que vamos escrever.
Um servidor pode expor três tipos de recurso, que a especificação chama de primitivas:
tools (ferramentas): funções que o modelo pode chamar, como consultar uma API ou gravar um arquivo;
resources (recursos): dados que servem de contexto, como o conteúdo de um arquivo ou o schema de um banco;
prompts: modelos de instrução reutilizáveis.
As mensagens seguem o JSON-RPC 2.0, um formato simples de requisição e resposta em JSON. Por baixo delas existe a camada de transporte, que pode ser de dois tipos. No stdio, o host inicia o servidor como um processo local e conversa com ele pela entrada e saída padrão. No Streamable HTTP, o servidor roda remotamente e atende vários clientes pela rede. Nosso servidor vai usar stdio, que é o caminho mais curto para rodar na própria máquina.
A API de Star Wars
A SWAPI é uma API REST gratuita, sem autenticação e só de leitura, com dados de personagens, planetas, naves, veículos, espécies e filmes da saga. Foi criada por Paul Hallett no endereço swapi.co, que saiu do ar, e hoje sobrevive em espelhos mantidos pela comunidade.
O espelho mais conhecido, o swapi.dev, aparece em muito tutorial, mas está com o certificado HTTPS expirado desde abril de 2025. O problema chegou a ser registrado na documentação do Angular, que usava a API como exemplo. Por isso vamos usar o swapi.tech, que expõe os mesmos recursos e continua funcionando.
A diferença entre os dois está no formato da resposta. O swapi.tech embrulha os dados originais em um envelope. Uma busca por personagem, em https://www.swapi.tech/api/people/?name=luke, devolve algo assim (resumido):
Dois detalhes vão influenciar o código. Os números chegam como texto ("172", não 172). E o planeta natal vem como uma URL, não como um nome, então vamos precisar de uma segunda requisição para resolvê-lo. Quando a busca não encontra nada, o campo result volta como uma lista vazia.
Preparando o projeto
O SDK oficial de TypeScript pede Node.js 20 ou mais recente. Crie a pasta e instale as dependências:
O type=module é obrigatório, porque o SDK é distribuído apenas como ES modules. O pacote @modelcontextprotocol/server é a versão 2 do SDK, a linha estável que implementa a especificação de julho de 2026. Quem já viu tutoriais antigos com @modelcontextprotocol/sdk está olhando para a versão 1. O tsx executa TypeScript direto, sem etapa de build, e o zod define os schemas de entrada das ferramentas.
O TypeScript em si fica como dependência de desenvolvimento, só para o editor e para a checagem de tipos. Um tsconfig.json mínimo:
O projeto termina com dois arquivos: src/swapi.ts, que conversa com a API, e src/index.ts, que é o servidor MCP.
O cliente da SWAPI
Separar o acesso à API do servidor deixa cada arquivo com uma responsabilidade. O cliente usa o fetch nativo do Node, então não precisa de nenhuma biblioteca HTTP, e valida cada resposta com Zod antes de devolver os dados.
import * as z from 'zod/v4';
const SWAPI_BASE_URL = 'https://www.swapi.tech/api';
const PersonSchema = z.object({
name: z.string(),
birth_year: z.string(),
gender: z.string(),
height: z.string(),
mass: z.string(),
homeworld: z.string(),
films: z.array(z.string())
});
const PlanetSchema = z.object({
name: z.string(),
climate: z.string(),
terrain: z.string(),
population: z.string(),
diameter: z.string()
});
const FilmSchema = z.object({
title: z.string(),
episode_id: z.number(),
director: z.string(),
release_date: z.string(),
opening_crawl: z.string()
});
function searchResponse<T extends z.ZodType>(properties: T) {
return z.object({
result: z.array(z.object({ uid: z.string(), properties }))
});
}
export type Person = z.infer<typeof PersonSchema>;
export type Planet = z.infer<typeof PlanetSchema>;
export type Film = z.infer<typeof FilmSchema>;
async function getJson(url: string): Promise<unknown> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`SWAPI respondeu HTTP ${response.status}`);
}
return response.json();
}
export async function searchPeople(name: string): Promise<Person[]> {
const url = `${SWAPI_BASE_URL}/people/?name=${encodeURIComponent(name)}`;
const data = searchResponse(PersonSchema).parse(await getJson(url));
return data.result.map(item => item.properties);
}
export async function searchPlanets(name: string): Promise<Planet[]> {
const url = `${SWAPI_BASE_URL}/planets/?name=${encodeURIComponent(name)}`;
const data = searchResponse(PlanetSchema).parse(await getJson(url));
return data.result.map(item => item.properties);
}
export async function searchFilms(title: string): Promise<Film[]> {
const url = `${SWAPI_BASE_URL}/films/?title=${encodeURIComponent(title)}`;
const data = searchResponse(FilmSchema).parse(await getJson(url));
return data.result.map(item => item.properties);
}
export async function getPlanetName(url: string): Promise<string> {
const data = z
.object({ result: z.object({ properties: z.object({ name: z.string() }) }) })
.parse(await getJson(url));
return data.result.properties.name;
}
A função searchResponse monta o schema do envelope do swapi.tech a partir do schema de cada recurso, o que evita repetir a mesma estrutura três vezes. O parse confere a resposta e lança erro se o formato mudar. Como o z.object descarta as chaves não declaradas, só os campos que interessam chegam ao servidor.
Essa validação pode parecer excesso para uma API de filmes, mas o servidor MCP é uma fronteira: o que ele recebe de fora vai parar no contexto de um modelo. Se a API mudar o formato, é melhor falhar com uma mensagem clara do que entregar ao agente um undefined que ele vai tentar interpretar. O artigo Validação com Zod no TypeScript explica essa ideia em detalhe.
Registrando as ferramentas
O servidor expõe três ferramentas: buscar personagem, buscar planeta e buscar filme. Cada uma é registrada com registerTool, que recebe um nome, uma configuração e a função que executa o trabalho, chamada de handler.
import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
import { getPlanetName, searchFilms, searchPeople, searchPlanets } from './swapi.js';
function textResult(text: string, isError = false) {
return { content: [{ type: 'text' as const, text }], isError };
}
function createServer(): McpServer {
const server = new McpServer({ name: 'swapi', version: '1.0.0' });
server.registerTool(
'search-character',
{
title: 'Buscar personagem',
description:
'Busca personagens de Star Wars pelo nome (busca parcial, sem diferenciar maiúsculas). ' +
'Retorna ano de nascimento, gênero, altura, massa e planeta natal.',
inputSchema: z.object({
name: z.string().min(2).describe('Nome ou parte do nome, em inglês. Ex.: "luke", "vader"')
}),
annotations: { readOnlyHint: true, openWorldHint: true }
},
async ({ name }) => {
try {
const people = await searchPeople(name);
if (people.length === 0) {
return textResult(`Nenhum personagem encontrado para "${name}".`);
}
const lines = await Promise.all(
people.map(async person => {
const homeworld = await getPlanetName(person.homeworld);
return [
`${person.name}`,
` nascimento: ${person.birth_year}`,
` gênero: ${person.gender}`,
` altura: ${person.height} cm, massa: ${person.mass} kg`,
` planeta natal: ${homeworld}`,
` aparece em ${person.films.length} filme(s)`
].join('\n');
})
);
return textResult(lines.join('\n\n'));
} catch (error) {
return textResult(`Falha ao consultar a SWAPI: ${(error as Error).message}`, true);
}
}
);
server.registerTool(
'search-planet',
{
title: 'Buscar planeta',
description: 'Busca planetas de Star Wars pelo nome. Retorna clima, terreno, população e diâmetro.',
inputSchema: z.object({
name: z.string().min(2).describe('Nome ou parte do nome do planeta. Ex.: "tatooine"')
}),
annotations: { readOnlyHint: true, openWorldHint: true }
},
async ({ name }) => {
try {
const planets = await searchPlanets(name);
if (planets.length === 0) {
return textResult(`Nenhum planeta encontrado para "${name}".`);
}
const lines = planets.map(planet =>
[
planet.name,
` clima: ${planet.climate}`,
` terreno: ${planet.terrain}`,
` população: ${planet.population}`,
` diâmetro: ${planet.diameter} km`
].join('\n')
);
return textResult(lines.join('\n\n'));
} catch (error) {
return textResult(`Falha ao consultar a SWAPI: ${(error as Error).message}`, true);
}
}
);
server.registerTool(
'search-film',
{
title: 'Buscar filme',
description: 'Busca filmes de Star Wars pelo título. Retorna episódio, diretor, data de lançamento e o texto de abertura.',
inputSchema: z.object({
title: z.string().min(2).describe('Título ou parte do título, em inglês. Ex.: "hope", "empire"')
}),
annotations: { readOnlyHint: true, openWorldHint: true }
},
async ({ title }) => {
try {
const films = await searchFilms(title);
if (films.length === 0) {
return textResult(`Nenhum filme encontrado para "${title}".`);
}
const lines = films.map(film =>
[
`Episódio ${film.episode_id}: ${film.title}`,
` direção: ${film.director}`,
` lançamento: ${film.release_date}`,
` abertura: ${film.opening_crawl.replace(/\s+/g, ' ').trim()}`
].join('\n')
);
return textResult(lines.join('\n\n'));
} catch (error) {
return textResult(`Falha ao consultar a SWAPI: ${(error as Error).message}`, true);
}
}
);
return server;
}
void serveStdio(createServer);
console.error('swapi MCP server running on stdio');
A configuração de cada ferramenta merece atenção, porque é tudo o que o modelo vê antes de decidir usá-la:
description diz o que a ferramenta faz e o que devolve. O modelo escolhe a ferramenta lendo esse texto, então ele funciona como documentação para a IA;
inputSchema é um schema Zod. A partir dele o SDK gera o JSON Schema anunciado ao cliente, valida os argumentos antes de o handler rodar e infere os tipos dos parâmetros. O texto do .describe() vira a descrição do campo, a única orientação que o modelo recebe sobre aquele argumento;
annotations são dicas de comportamento. O readOnlyHint avisa que a ferramenta não altera nada e o openWorldHint indica que ela acessa um sistema externo. O host pode usar isso para aprovar chamadas de leitura automaticamente e pedir confirmação para as que modificam dados.
O handler devolve uma lista de blocos de conteúdo. Aqui usamos só texto, que é o suficiente para o modelo ler e responder ao usuário. Na ferramenta de personagens, o Promise.all resolve os planetas natais em paralelo, em vez de fazer uma requisição depois da outra.
No fim do arquivo, o serveStdio recebe a função createServer e cuida do transporte: lê as requisições da entrada padrão, escreve as respostas na saída padrão e cria a instância do servidor para a conexão.
Quando algo dá errado
Existem dois tipos de erro, e o SDK trata o primeiro sozinho. Se o modelo mandar um argumento que não bate com o schema, como um nome de uma letra só, o handler nem chega a rodar. O cliente recebe um resultado comum marcado com isError: true:
Input validation error: Invalid arguments for tool search-character:
name: Too small: expected string to have >=2 characters
O segundo tipo acontece dentro do handler: a API fora do ar, um erro HTTP, uma resposta com formato inesperado. Para esses casos o código usa try/catch e também devolve isError: true, com uma mensagem legível. A ideia é a mesma nos dois casos. Um erro devolvido como resultado chega ao modelo, que pode ler a mensagem, corrigir o argumento e tentar de novo. Uma exceção sem tratamento só derruba a chamada.
O cuidado com o console.log
No transporte stdio, a saída padrão é o canal do protocolo. Tudo o que for escrito nela é interpretado como mensagem JSON-RPC. Um único console.log no meio do código corrompe a conversa, e o host derruba a conexão.
Por isso o servidor escreve a mensagem de inicialização com console.error, que vai para a saída de erro. Vale a mesma regra para qualquer log de depuração: sempre console.error, nunca console.log.
Rode o servidor para conferir:
npx tsx src/index.ts
Ele imprime swapi MCP server running on stdio e fica parado. Isso é o esperado: um servidor stdio aguarda um cliente iniciar a conversa. Encerre com Ctrl+C.
Testando com o MCP Inspector
O MCP Inspector é uma ferramenta oficial que abre uma interface web para chamar as ferramentas de um servidor diretamente, sem precisar de um modelo no meio. Ele recebe o comando que inicia o servidor:
Na aba do navegador que abrir, clique em Connect, vá até Tools e liste as ferramentas. As três aparecem com o título, a descrição e os campos gerados a partir do schema Zod. Escolha search-character, digite vader e execute. O resultado é o mesmo texto que o modelo vai receber quando chamar a ferramenta.
Vale testar também o caminho de erro. Mande uma letra só no campo de nome e veja a mensagem de validação voltar sem que a SWAPI seja consultada.
Ligando o servidor a um agente
Para usar o servidor, o host precisa de uma única informação: o comando que inicia o processo. É o mesmo que já usamos, npx tsx src/index.ts, executado a partir da raiz do projeto. Muda apenas o lugar onde cada aplicação guarda essa configuração.
No Claude Code, registre o servidor a partir da pasta do projeto. Tudo o que vem depois do -- é o comando de inicialização:
claude mcp add swapi -- npx tsx src/index.ts
Dentro de uma sessão, o comando /mcp mostra o servidor conectado e as ferramentas disponíveis.
No VS Code, crie o arquivo .vscode/mcp.json na raiz do projeto:
O editor pede confirmação para confiar no servidor. Depois disso, abra o Copilot Chat no modo Agent, que é o único modo do Copilot que chama ferramentas.
No Cursor, o arquivo é .cursor/mcp.json, com a chave mcpServers no lugar de servers e sem o campo type.
Com o servidor ligado, faça uma pergunta que exija dados, como “Qual é o planeta natal do Obi-Wan e como é o clima lá?”. Repare que a pergunta não cita nenhuma ferramenta. O modelo escolhe search-character e depois search-planet a partir do nome, da descrição e do schema de cada uma.
Se o host não enxergar as ferramentas, rode o comando de inicialização à mão antes de mexer na configuração. Se o processo quebrar, o erro aparece ali. Se ele imprimir qualquer coisa além da linha de inicialização, procure um console.log perdido.
O que acontece numa chamada
A resposta que aparece no chat é o resultado de seis passos:
o host envia a pergunta ao modelo, junto com o nome, a descrição e o schema de cada ferramenta disponível;
o modelo decide que search-character resolve a pergunta e gera uma chamada com os argumentos que escolheu;
o cliente MCP dentro do host envia uma requisição tools/call ao servidor pelo stdio;
o SDK valida os argumentos com o schema Zod e executa o handler;
o handler consulta a SWAPI e devolve o conteúdo pela saída padrão;
o modelo lê o texto e escreve a resposta final.
O ponto central é que o modelo nunca acessa a SWAPI. Quem faz a requisição é o seu código, com as regras que você definiu. O modelo só decide quando pedir e o que fazer com o resultado.
Próximos passos
O servidor deste artigo cobre o essencial, mas o SDK vai além. Com outputSchema e structuredContent, a ferramenta devolve também um objeto estruturado e validado, útil quando outro programa, e não só o modelo, vai consumir o resultado. Os recursos e prompts permitem expor, por exemplo, a lista de filmes como contexto fixo. E a mesma função createServer pode ser servida por HTTP, o que transforma o servidor local em um serviço que vários clientes acessam pela rede.
A troca da SWAPI por uma API de verdade, como a do seu sistema, não muda a estrutura. Continua sendo um cliente HTTP validado, um schema de entrada bem descrito e um handler que devolve texto ou erro.
Referências
ANGULAR. StarWars API in doc example no longer available: issue #62213. GitHub, 2025. Disponível em: https://github.com/angular/angular/issues/62213. Acesso em: 22 set. 2026.
ANTHROPIC. Introducing the Model Context Protocol. Anthropic, 2024. Disponível em: https://www.anthropic.com/news/model-context-protocol. Acesso em: 22 set. 2026.
BOLSANELLO, Paulo Roberto. Validação com Zod no TypeScript: tipos que também funcionam em tempo de execução. paulorb.dev, 2026. Disponível em: https://paulorb.dev/blog/validacao-com-zod-no-typescript-tipos-que-tambem-funcionam-em-tempo-de-execucao. Acesso em: 22 set. 2026.
MODEL CONTEXT PROTOCOL. Architecture overview. Model Context Protocol, [2026]. Disponível em: https://modelcontextprotocol.io/docs/learn/architecture. Acesso em: 22 set. 2026.
MODEL CONTEXT PROTOCOL. Build your first server. MCP TypeScript SDK, 2026. Disponível em: https://ts.sdk.modelcontextprotocol.io/v2/get-started/first-server.html. Acesso em: 22 set. 2026.
MODEL CONTEXT PROTOCOL. Plug into a real host. MCP TypeScript SDK, 2026. Disponível em: https://ts.sdk.modelcontextprotocol.io/v2/get-started/real-host.html. Acesso em: 22 set. 2026.
Introdução ao SQLite O SQLite é um sistema de banco de dados leve, sem servidor e autocontido, amplamente utilizado em aplicações locais e embarcadas. Ele armazena dados em arquivos individuais e oferece suporte a…
O método PDO::pgsqlCopyFromArray permite copiar dados de um array diretamente para uma tabela no PostgreSQL. Este tutorial mostra como usar esse método no contexto do Laravel. Os métodos do PDO no Laravel podem ser…