Open7

ポケモン API と modelcontextprotocol/typescript-sdk を使って、自作 MCP サーバを作ってみる

Naoto ImamachiNaoto Imamachi

ポケモン API を使って、自作のポケモン博士 MCP サーバを作ろう!

MCPサーバってそもそも何もの?

MCP(Model Context Protocol)は、昨年2024年11月にAnthropicによってオープンソースとして発表された、AIアプリケーション(Cursor, Cline等)が外部サービスやデータとやり取りするための標準規格のこと。

MCP サーバは、AI が外部 API やリソースにアクセスするための仲介役みたいなもの(たぶん)。AI が、各種サービスとコミュニケーションするために必要。

また、MCP の登場によって、ChatGPT や Gemini、Claude などの AI が外部リソースにアクセスするための実装を個別に行う必要がなくなった。

とりあえず、このあたりの記事を読んでおこう。
https://zenn.dev/cloud_ace/articles/model-context-protocol

なんかイケてる図。わかりやすいような、わかりにくいような。
https://x.com/norahsakal/status/1898183864570593663

MCP サーバの要素

ぶっちゃけ、tools だけでも動く。

Resources

LLM(AI)に渡す context(データ)を設定できる。AI に事前情報を与えておきたいときに使う。
REST API でいうところの GET エンドポイントみたいなもの(公式のこの喩えはわかりやすいような、わかりにくいような)

Tools

LLM(AI)が MCP サーバを介して、アクションを実行させる処理を設定できる。
REST API でいうところの POST エンドポイントみたいなもの

Prompts

言わずもがな、プロンプト。MCP サーバを介して、LLM(AI)とやりとりする際に使われるプロンプトを定義できる。

MCP サーバの種類

stdio

ローカルで起動して、MCP サーバとして活用する場合はこちら。今回の例では便宜上こっちを使います。

HTTP with SSE

MCP サーバを AWS などのクラウド上にデプロイして、クライアントからアクセスさせたい場合はこちら。一般的なサービスとして展開するならこちらかも?

環境構築

pnpm をインストール

brew install pnpm

node の 23.11.0 をインストール(最新であればなんでもいい)

nodenv install 23.11.0
mkdir my-mcp-test
cd my-mcp-test

npm init
pnpm add @modelcontextprotocol/sdk zod
pnpm add typescript ts-node @types/node -D
npx tsc --init

tsconfig.json ファイルは以下。
型エラーになるので、NodeNext などに置き換える。

{
  "compilerOptions": {
    /* Visit https://aka.ms/tsconfig to read more about this file */
    "target": "es2022",                                  /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
    "module": "NodeNext",                                /* Specify what module code is generated. */
    "moduleResolution": "nodenext",                     /* Specify how TypeScript looks up a file from a given module specifier. */
    "esModuleInterop": true,                             /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
    "forceConsistentCasingInFileNames": true,            /* Ensure that casing is correct in imports. */
    "strict": true,                                      /* Enable all strict type-checking options. */
    "skipLibCheck": true                                 /* Skip type checking all .d.ts files. */
  }
}
Naoto ImamachiNaoto Imamachi

ソースコード

src/index.ts のコード
(AI に適当に書いてもらった)

  • get_random_pokemon: ランダムなポケモンの情報を取得する
  • search_pokemon_by_japanese_name: 日本語のポケモン名で検索する
import {
  McpServer,
  ResourceTemplate,
} from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import axios from "axios";

// Total number of Pokemon in the API (as of Gen 9)
const TOTAL_POKEMON = 1025;

// Define types for Pokemon API responses
interface PokemonType {
  type: {
    name: string;
  };
}

interface PokemonStat {
  base_stat: number;
  stat: {
    name: string;
  };
}

interface PokemonAbility {
  ability: {
    name: string;
  };
}

interface PokemonData {
  id: number;
  name: string;
  height: number;
  weight: number;
  types: PokemonType[];
  sprites: {
    front_default: string;
  };
  stats: PokemonStat[];
  abilities: PokemonAbility[];
}

// Interface for Pokemon species data that includes localized names
interface PokemonSpeciesName {
  name: string;
  language: {
    name: string;
  };
}

interface PokemonSpecies {
  id: number;
  name: string;
  names: PokemonSpeciesName[];
}

// Function to fetch a random Pokemon from the PokeAPI
async function fetchRandomPokemon() {
  try {
    // Generate a random Pokemon ID between 1 and TOTAL_POKEMON
    const randomId = Math.floor(Math.random() * TOTAL_POKEMON) + 1;

    // Fetch the Pokemon data
    const response = await axios.get<PokemonData>(
      `https://pokeapi.co/api/v2/pokemon/${randomId}`
    );

    // Extract relevant information
    const { id, name, height, weight, types, sprites, stats, abilities } =
      response.data;

    // Format the response
    return {
      id,
      name,
      height: height / 10, // Convert to meters
      weight: weight / 10, // Convert to kilograms
      types: types.map((type) => type.type.name),
      imageUrl: sprites.front_default,
      stats: stats.map((stat) => ({
        name: stat.stat.name,
        value: stat.base_stat,
      })),
      abilities: abilities.map((ability) => ability.ability.name),
    };
  } catch (error) {
    console.error("Error fetching Pokemon:", error);
    throw new Error("Failed to fetch Pokemon data");
  }
}

// Function to fetch image as base64
async function fetchImageAsBase64(
  url: string
): Promise<{ data: string; mimeType: string }> {
  try {
    const response = await axios.get(url, { responseType: "arraybuffer" });
    const buffer = Buffer.from(response.data, "binary");
    const base64 = buffer.toString("base64");
    const mimeType = response.headers["content-type"] || "image/png";
    return { data: base64, mimeType };
  } catch (error) {
    console.error("Error fetching image:", error);
    throw new Error("Failed to fetch image data");
  }
}

// Function to fetch Pokemon by Japanese name
async function fetchPokemonByJapaneseName(japaneseName: string) {
  try {
    // First, we need to get a list of Pokemon species to search through
    // We'll limit to the first 100 for performance, but in a real app you might want to paginate
    const speciesListResponse = await axios.get(
      `https://pokeapi.co/api/v2/pokemon-species?limit=1000`
    );

    // Fetch all species details to find the one with matching Japanese name
    const speciesPromises = speciesListResponse.data.results.map(
      (species: { url: string }) => axios.get(species.url)
    );

    // Use Promise.all to fetch all species data in parallel
    const speciesResponses = await Promise.all(speciesPromises);
    const allSpecies = speciesResponses.map((response) => response.data);

    // Find the species with the matching Japanese name
    const matchingSpecies = allSpecies.find((species: PokemonSpecies) => {
      const japaneseNameObj = species.names.find(
        (nameObj) =>
          nameObj.language.name === "ja" && nameObj.name === japaneseName
      );
      return !!japaneseNameObj;
    });

    if (!matchingSpecies) {
      throw new Error(`ポケモンが見つかりませんでした: ${japaneseName}`);
    }

    // Now fetch the Pokemon data using the species ID
    const pokemonResponse = await axios.get<PokemonData>(
      `https://pokeapi.co/api/v2/pokemon/${matchingSpecies.id}`
    );

    // Extract relevant information
    const { id, name, height, weight, types, sprites, stats, abilities } =
      pokemonResponse.data;

    // Format the response
    return {
      id,
      name,
      japaneseName,
      height: height / 10, // Convert to meters
      weight: weight / 10, // Convert to kilograms
      types: types.map((type) => type.type.name),
      imageUrl: sprites.front_default,
      stats: stats.map((stat) => ({
        name: stat.stat.name,
        value: stat.base_stat,
      })),
      abilities: abilities.map((ability) => ability.ability.name),
    };
  } catch (error) {
    console.error("Error fetching Pokemon by Japanese name:", error);
    throw error;
  }
}

// Optimized version that doesn't fetch all species at once
async function searchPokemonByJapaneseName(japaneseName: string) {
  try {
    // Get the list of all Pokemon species (paginated)
    const limit = 100;
    let offset = 0;
    let foundPokemon = null;

    // Keep searching through pages until we find a match or run out of Pokemon
    while (!foundPokemon) {
      const speciesListResponse = await axios.get(
        `https://pokeapi.co/api/v2/pokemon-species?limit=${limit}&offset=${offset}`
      );

      // If there are no more results, break the loop
      if (speciesListResponse.data.results.length === 0) {
        break;
      }

      // Fetch each species to check its Japanese name
      for (const speciesEntry of speciesListResponse.data.results) {
        const speciesResponse = await axios.get(speciesEntry.url);
        const species = speciesResponse.data;

        // Check if this species has the matching Japanese name
        const japaneseNameObj = species.names.find(
          (nameObj: PokemonSpeciesName) =>
            nameObj.language.name === "ja" &&
            nameObj.name.toLowerCase() === japaneseName.toLowerCase()
        );

        if (japaneseNameObj) {
          // We found a match! Now get the full Pokemon data
          const pokemonResponse = await axios.get<PokemonData>(
            `https://pokeapi.co/api/v2/pokemon/${species.id}`
          );

          const { id, name, height, weight, types, sprites, stats, abilities } =
            pokemonResponse.data;

          foundPokemon = {
            id,
            name,
            japaneseName: japaneseNameObj.name,
            height: height / 10,
            weight: weight / 10,
            types: types.map((type) => type.type.name),
            imageUrl: sprites.front_default,
            stats: stats.map((stat) => ({
              name: stat.stat.name,
              value: stat.base_stat,
            })),
            abilities: abilities.map((ability) => ability.ability.name),
          };

          break;
        }
      }

      // If we haven't found a match, move to the next page
      if (!foundPokemon) {
        offset += limit;

        // If we've checked more than 1000 Pokemon, stop to prevent excessive API calls
        if (offset > 1000) {
          break;
        }
      }
    }

    if (!foundPokemon) {
      throw new Error(`ポケモンが見つかりませんでした: ${japaneseName}`);
    }

    return foundPokemon;
  } catch (error) {
    console.error("Error searching Pokemon by Japanese name:", error);
    throw error;
  }
}

// Create an MCP server
const server = new McpServer({
  name: "PokemonServer",
  version: "1.0.0",
});

// Add a tool to get a random Pokemon
server.tool(
  "get_random_pokemon",
  "ランダムなポケモンの情報を取得する",
  {},
  async () => {
    try {
      const pokemon = await fetchRandomPokemon();

      // Fetch the image as base64
      const imageData = await fetchImageAsBase64(pokemon.imageUrl);

      // Format the response in a readable way
      const formattedResponse = `
ポケモン情報:
ID: ${pokemon.id}
名前: ${pokemon.name}
タイプ: ${pokemon.types.join(", ")}
高さ: ${pokemon.height}m
重さ: ${pokemon.weight}kg
画像URL: ${pokemon.imageUrl}

能力値:
${pokemon.stats.map((stat) => `- ${stat.name}: ${stat.value}`).join("\n")}

特性:
${pokemon.abilities.join(", ")}
      `;

      return {
        content: [
          { type: "text", text: formattedResponse },
          {
            type: "image",
            data: imageData.data,
            mimeType: imageData.mimeType,
          },
        ],
      };
    } catch (error) {
      console.error("Error in get_random_pokemon tool:", error);
      return {
        content: [
          { type: "text", text: "ポケモンデータの取得に失敗しました。" },
        ],
      };
    }
  }
);

// Add a tool to search for Pokemon by Japanese name
server.tool(
  "search_pokemon_by_japanese_name",
  "日本語のポケモン名で検索する",
  { japaneseName: z.string().describe("検索したいポケモンの日本語名") },
  async ({ japaneseName }) => {
    try {
      const pokemon = await searchPokemonByJapaneseName(japaneseName);

      // Fetch the image as base64
      const imageData = await fetchImageAsBase64(pokemon.imageUrl);

      // Format the response in a readable way
      const formattedResponse = `
ポケモン情報:
ID: ${pokemon.id}
英語名: ${pokemon.name}
日本語名: ${pokemon.japaneseName}
タイプ: ${pokemon.types.join(", ")}
高さ: ${pokemon.height}m
重さ: ${pokemon.weight}kg
画像URL: ${pokemon.imageUrl}

能力値:
${pokemon.stats.map((stat) => `- ${stat.name}: ${stat.value}`).join("\n")}

特性:
${pokemon.abilities.join(", ")}
      `;

      return {
        content: [
          { type: "text", text: formattedResponse },
          {
            type: "image",
            data: imageData.data,
            mimeType: imageData.mimeType,
          },
        ],
      };
    } catch (error) {
      console.error("Error in search_pokemon_by_japanese_name tool:", error);
      return {
        content: [
          {
            type: "text",
            text:
              error instanceof Error
                ? error.message
                : "ポケモンデータの取得に失敗しました。",
          },
        ],
      };
    }
  }
);

// Start receiving messages on stdin and sending messages on stdout
const transport = new StdioServerTransport();

// Add debugging logs to identify where the issue occurs
console.log("Starting MCP server...");
(async () => {
  try {
    console.log("Connecting to transport...");
    await server.connect(transport);
    console.log("Server connected successfully.");
  } catch (error) {
    console.error("Error during server connection:", error);
    process.exit(1);
  }
})();
Naoto ImamachiNaoto Imamachi

VSCode 側の設定

たぶん、VSCode と GitHub Copilot を使う分には無料でいけるだろうという思惑のもと、VSCode で試してみる。

vscode の settings.json に以下を追加。
${your-path} は自身の環境のフルパスを指定。
GitHub Copilot の Agent mode は、最近 GA になったので、デフォルトで OFF になっている模様。
なので、設定で ON にしておく。

{
  "mcp": {
    "servers": {
      "demo": {
       "command": "node",
        "args": ["/${your-path}/my-mcp-test/src/index.ts"]
      }
    }
  },
  "chat.agent.enabled": true
}
Naoto ImamachiNaoto Imamachi

実際に動かしてみる

search_pokemon_by_japanese_name: 日本語のポケモン名で検索する

以下のような文章を AI が読み取って、ポケモン名をよしなに抽出してくれる。
その抽出されたポケモン名を使って、ポケモン API を叩いて、レスポンスを返す。

「ピカチュウについて教えて下さい」
「俺の好きなポケモンであるピッピについて教えてくれ!」
「ひらがなも行けるの?らふれしあとかいける?」

Naoto ImamachiNaoto Imamachi

MCP Inspector を使う

動作検証のたびに AI を使うと、場合によっては課金の問題が出てくる。そこで、AI を使わずに MCP サーバの動作検証を行うことができるツールが、MCP Inspector というわけだ(AI を介していないのでコストゼロのはず)

また、このツールを使うことで、Claude Desktop などのクライアントを持っていなくても、動作検証が可能となる。

https://github.com/modelcontextprotocol/inspector

サーバを起動させた状態で、Ping を打って、サーバが生きているか確認できる。
Resources, Tools, Prompts, のそれぞれを確認できる。

TS ファイルを解釈できる Node バージョンであることを前提として、以下のコマンドで起動。

npx @modelcontextprotocol/inspector node src/index.ts

以下例。
AI が自然言語を解釈して、ポケモン名だけを抜き出してくれないので、ダイレクトにポケモン名を指定して実行する。

Request と Reponse を確認もできる。

もちろん、テストコードを書いて動作確認することも大事だが、MCP Inspector を使うことで E2E レベルの動作検証が可能となる。

Naoto ImamachiNaoto Imamachi

まだちょっと良くわかってない点

tools って、ユーザーのコメントに応じて自動的に切り替わるものではない?だとすると、一つの tools の中に複数の命令系統を組み込んでおいて、中で条件分岐させないといけない?(なんか、それはダルい)

このあたり、参考になるかも。

https://github.com/modelcontextprotocol/typescript-sdk?tab=readme-ov-file#dynamic-servers