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

ポケモン API を使って、自作のポケモン博士 MCP サーバを作ろう!
MCPサーバってそもそも何もの?
MCP(Model Context Protocol)は、昨年2024年11月にAnthropicによってオープンソースとして発表された、AIアプリケーション(Cursor, Cline等)が外部サービスやデータとやり取りするための標準規格のこと。
MCP サーバは、AI が外部 API やリソースにアクセスするための仲介役みたいなもの(たぶん)。AI が、各種サービスとコミュニケーションするために必要。
また、MCP の登場によって、ChatGPT や Gemini、Claude などの AI が外部リソースにアクセスするための実装を個別に行う必要がなくなった。
とりあえず、このあたりの記事を読んでおこう。
なんかイケてる図。わかりやすいような、わかりにくいような。
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. */
}
}

ソースコード
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);
}
})();

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
}

実際に動かしてみる
search_pokemon_by_japanese_name: 日本語のポケモン名で検索する
以下のような文章を AI が読み取って、ポケモン名をよしなに抽出してくれる。
その抽出されたポケモン名を使って、ポケモン API を叩いて、レスポンスを返す。
「ピカチュウについて教えて下さい」
「俺の好きなポケモンであるピッピについて教えてくれ!」
「ひらがなも行けるの?らふれしあとかいける?」

参考
MCP SDK(TypeScript 向け)
MCP サーバのサンプル集
VSCode で MCP サーバを使う

MCP Inspector を使う
動作検証のたびに AI を使うと、場合によっては課金の問題が出てくる。そこで、AI を使わずに MCP サーバの動作検証を行うことができるツールが、MCP Inspector というわけだ(AI を介していないのでコストゼロのはず)
また、このツールを使うことで、Claude Desktop などのクライアントを持っていなくても、動作検証が可能となる。
サーバを起動させた状態で、Ping を打って、サーバが生きているか確認できる。
Resources, Tools, Prompts, のそれぞれを確認できる。
TS ファイルを解釈できる Node バージョンであることを前提として、以下のコマンドで起動。
npx @modelcontextprotocol/inspector node src/index.ts
以下例。
AI が自然言語を解釈して、ポケモン名だけを抜き出してくれないので、ダイレクトにポケモン名を指定して実行する。
Request と Reponse を確認もできる。
もちろん、テストコードを書いて動作確認することも大事だが、MCP Inspector を使うことで E2E レベルの動作検証が可能となる。

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