Criando um servidor MCP em TypeScript com a API de Star Wars

Criando um servidor MCP em TypeScript com a API de Star Wars

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 que é o MCP

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):

{
  "message": "ok",
  "result": [
    {
      "uid": "1",
      "properties": {
        "name": "Luke Skywalker",
        "birth_year": "19BBY",
        "gender": "male",
        "height": "172",
        "mass": "77",
        "homeworld": "https://www.swapi.tech/api/planets/1",
        "films": ["https://www.swapi.tech/api/films/1", "..."]
      }
    }
  ]
}

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:

mkdir swapi-mcp && cd swapi-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server zod tsx
npm install -D typescript @types/node
mkdir src

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:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

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:

npx @modelcontextprotocol/inspector npx tsx src/index.ts

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:

{
  "servers": {
    "swapi": {
      "type": "stdio",
      "command": "npx",
      "args": ["tsx", "src/index.ts"]
    }
  }
}

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:

  1. o host envia a pergunta ao modelo, junto com o nome, a descrição e o schema de cada ferramenta disponível;
  2. o modelo decide que search-character resolve a pergunta e gera uma chamada com os argumentos que escolheu;
  3. o cliente MCP dentro do host envia uma requisição tools/call ao servidor pelo stdio;
  4. o SDK valida os argumentos com o schema Zod e executa o handler;
  5. o handler consulta a SWAPI e devolve o conteúdo pela saída padrão;
  6. 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.
  • MODEL CONTEXT PROTOCOL. Tools. MCP TypeScript SDK, 2026. Disponível em: https://ts.sdk.modelcontextprotocol.io/v2/servers/tools.html. Acesso em: 22 set. 2026.
  • SWAPI. Documentation. SWAPI: The Star Wars API, [2026]. Disponível em: https://swapi.dev/documentation. Acesso em: 22 set. 2026.
  • SWAPI TECH. SWAPI: A New Hope. swapi.tech, [2026]. Disponível em: https://www.swapi.tech/. Acesso em: 22 set. 2026.

Comentários (0)

Nenhum comentário ainda. Seja o primeiro.

Continue lendo

Instalando e Gerenciando o SQLite no Linux

Instalando e Gerenciando o SQLite no Linux

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…

Artigo, Iniciantes, Linux