Skip to main content
Glama
grovesjosephn

Pokemon MCP Server

index.ts.oldβ€’18.9 kB
// Pokemon Data Ingestion App // Fetches data from PokeAPI and stores in SQLite for MCP server import Database from 'better-sqlite3'; import fetch from 'node-fetch'; import { promises as fs } from 'fs'; import path from 'path'; interface Pokemon { id: number; name: string; height: number; weight: number; base_experience: number; generation: number; species?: { name: string; url: string }; sprites: { front_default: string; back_default: string; front_shiny: string; back_shiny: string; }; stats: Array<{ stat: { name: string; url: string }; base_stat: number; effort: number; }>; types: Array<{ slot: number; type: { name: string; url: string }; }>; abilities: Array<{ ability: { name: string; url: string }; is_hidden: boolean; slot: number; }>; } interface PokemonListResponse { count: number; next: string | null; previous: string | null; results: Array<{ name: string; url: string; }>; } interface TypeResponse { count: number; next: string | null; previous: string | null; results: Array<{ name: string; url: string; }>; } interface SpeciesResponse { generation: { name: string; url: string; }; } interface IngestionConfig { maxPokemon: number; batchSize: number; batchDelayMs: number; requestDelayMs: number; maxRetries: number; timeoutMs: number; } class RateLimiter { private lastRequestTime = 0; private requestCount = 0; private readonly minInterval: number; private readonly maxRequestsPerMinute: number; constructor(requestsPerMinute = 100) { this.maxRequestsPerMinute = requestsPerMinute; this.minInterval = 60000 / requestsPerMinute; // ms between requests } async waitForSlot(): Promise<void> { const now = Date.now(); const timeSinceLastRequest = now - this.lastRequestTime; if (timeSinceLastRequest < this.minInterval) { const waitTime = this.minInterval - timeSinceLastRequest; await new Promise(resolve => setTimeout(resolve, waitTime)); } this.lastRequestTime = Date.now(); this.requestCount++; } getStats(): { requestCount: number; averageInterval: number } { return { requestCount: this.requestCount, averageInterval: this.requestCount > 1 ? (Date.now() - this.lastRequestTime) / this.requestCount : 0 }; } } class PokemonDataIngestion { private db: Database.Database; private baseUrl: string; private config: IngestionConfig; private rateLimiter: RateLimiter; constructor(customConfig?: Partial<IngestionConfig>) { // Load configuration with defaults this.config = this.loadConfig(customConfig); this.validateConfig(); // Setup rate limiter this.rateLimiter = new RateLimiter(60); // 60 requests per minute max // Ensure data directory exists const dataDir = path.resolve(process.cwd(), '../../data'); fs.mkdir(dataDir, { recursive: true }).catch(console.error); const dbPath = path.join(dataDir, 'pokemon.sqlite'); this.db = new Database(dbPath); this.baseUrl = 'https://pokeapi.co/api/v2'; this.setupDatabase(); console.log('πŸ“Š Ingestion Configuration:'); console.log(` Max Pokemon: ${this.config.maxPokemon}`); console.log(` Batch Size: ${this.config.batchSize}`); console.log(` Batch Delay: ${this.config.batchDelayMs}ms`); console.log(` Request Delay: ${this.config.requestDelayMs}ms`); console.log(` Max Retries: ${this.config.maxRetries}`); console.log(` Timeout: ${this.config.timeoutMs}ms`); } private loadConfig(customConfig?: Partial<IngestionConfig>): IngestionConfig { const defaultConfig: IngestionConfig = { maxPokemon: parseInt(process.env.POKEMON_LIMIT || '1025'), // Official Pokemon count batchSize: parseInt(process.env.BATCH_SIZE || '10'), batchDelayMs: parseInt(process.env.BATCH_DELAY_MS || '2000'), requestDelayMs: parseInt(process.env.REQUEST_DELAY_MS || '100'), maxRetries: parseInt(process.env.MAX_RETRIES || '3'), timeoutMs: parseInt(process.env.TIMEOUT_MS || '30000') }; return { ...defaultConfig, ...customConfig }; } private validateConfig(): void { const { maxPokemon, batchSize, batchDelayMs, requestDelayMs, maxRetries, timeoutMs } = this.config; if (maxPokemon < 1 || maxPokemon > 10000) { throw new Error(`Invalid maxPokemon: ${maxPokemon}. Must be between 1 and 10,000`); } if (batchSize < 1 || batchSize > 50) { throw new Error(`Invalid batchSize: ${batchSize}. Must be between 1 and 50`); } if (batchDelayMs < 100 || batchDelayMs > 60000) { throw new Error(`Invalid batchDelayMs: ${batchDelayMs}. Must be between 100ms and 60s`); } if (requestDelayMs < 0 || requestDelayMs > 5000) { throw new Error(`Invalid requestDelayMs: ${requestDelayMs}. Must be between 0ms and 5s`); } if (maxRetries < 1 || maxRetries > 10) { throw new Error(`Invalid maxRetries: ${maxRetries}. Must be between 1 and 10`); } if (timeoutMs < 1000 || timeoutMs > 300000) { throw new Error(`Invalid timeoutMs: ${timeoutMs}. Must be between 1s and 5min`); } } setupDatabase() { // Create tables for Pokemon data this.db.exec(` CREATE TABLE IF NOT EXISTS pokemon ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, height INTEGER, weight INTEGER, base_experience INTEGER, generation INTEGER, species_url TEXT, sprite_url TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS stats ( id INTEGER PRIMARY KEY AUTOINCREMENT, pokemon_id INTEGER, stat_name TEXT, base_stat INTEGER, effort INTEGER, FOREIGN KEY (pokemon_id) REFERENCES pokemon (id) ); CREATE TABLE IF NOT EXISTS types ( id INTEGER PRIMARY KEY, name TEXT UNIQUE NOT NULL ); CREATE TABLE IF NOT EXISTS pokemon_types ( pokemon_id INTEGER, type_id INTEGER, slot INTEGER, PRIMARY KEY (pokemon_id, type_id), FOREIGN KEY (pokemon_id) REFERENCES pokemon (id), FOREIGN KEY (type_id) REFERENCES types (id) ); CREATE TABLE IF NOT EXISTS abilities ( id INTEGER PRIMARY KEY, name TEXT UNIQUE NOT NULL, is_hidden BOOLEAN DEFAULT FALSE ); CREATE TABLE IF NOT EXISTS pokemon_abilities ( pokemon_id INTEGER, ability_id INTEGER, is_hidden BOOLEAN DEFAULT FALSE, slot INTEGER, PRIMARY KEY (pokemon_id, ability_id), FOREIGN KEY (pokemon_id) REFERENCES pokemon (id), FOREIGN KEY (ability_id) REFERENCES abilities (id) ); CREATE TABLE IF NOT EXISTS moves ( id INTEGER PRIMARY KEY, name TEXT UNIQUE NOT NULL, power INTEGER, accuracy INTEGER, pp INTEGER, type_id INTEGER, damage_class TEXT, FOREIGN KEY (type_id) REFERENCES types (id) ); CREATE TABLE IF NOT EXISTS pokemon_moves ( pokemon_id INTEGER, move_id INTEGER, learn_method TEXT, level_learned INTEGER, PRIMARY KEY (pokemon_id, move_id, learn_method), FOREIGN KEY (pokemon_id) REFERENCES pokemon (id), FOREIGN KEY (move_id) REFERENCES moves (id) ); -- Indexes for performance CREATE INDEX IF NOT EXISTS idx_pokemon_name ON pokemon (name); CREATE INDEX IF NOT EXISTS idx_pokemon_generation ON pokemon (generation); CREATE INDEX IF NOT EXISTS idx_stats_pokemon ON stats (pokemon_id); CREATE INDEX IF NOT EXISTS idx_pokemon_types_pokemon ON pokemon_types (pokemon_id); `); console.log('βœ… Database schema created'); } async fetchWithRetry(url: string, retries?: number): Promise<any> { if (!url) { throw new Error('URL is required'); } const maxRetries = retries ?? this.config.maxRetries; // Apply rate limiting await this.rateLimiter.waitForSlot(); // Add request delay if configured if (this.config.requestDelayMs > 0) { await this.delay(this.config.requestDelayMs); } for (let i = 0; i < maxRetries; i++) { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.config.timeoutMs); const response = await fetch(url, { signal: controller.signal, headers: { 'User-Agent': 'Pokemon-MCP-Server/1.0.0' } }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); // Basic content validation if (!data || typeof data !== 'object') { throw new Error('Invalid response: not a JSON object'); } return data; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); console.log(`⚠️ Retry ${i + 1}/${maxRetries} for ${url}: ${errorMsg}`); if (i === maxRetries - 1) { throw new Error(`Failed after ${maxRetries} retries: ${errorMsg}`); } // Exponential backoff with jitter const backoffMs = Math.min(1000 * Math.pow(2, i), 10000); const jitter = Math.random() * 1000; await this.delay(backoffMs + jitter); } } } delay(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); } async ingestTypes(): Promise<void> { console.log('πŸ”„ Fetching Pokemon types...'); const typesData = (await this.fetchWithRetry( `${this.baseUrl}/type` )) as TypeResponse; const insertType = this.db.prepare( 'INSERT OR IGNORE INTO types (id, name) VALUES (?, ?)' ); for (const type of typesData.results) { const typeId = parseInt(type.url.split('/').slice(-2, -1)[0]); insertType.run(typeId, type.name); } console.log(`βœ… Inserted ${typesData.results.length} types`); } async ingestPokemonBatch( pokemonList: Array<{ name: string; url: string }> ): Promise<number> { const insertPokemon = this.db.prepare(` INSERT OR REPLACE INTO pokemon (id, name, height, weight, base_experience, generation, species_url, sprite_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `); const insertStat = this.db.prepare(` INSERT OR REPLACE INTO stats (pokemon_id, stat_name, base_stat, effort) VALUES (?, ?, ?, ?) `); const insertPokemonType = this.db.prepare(` INSERT OR REPLACE INTO pokemon_types (pokemon_id, type_id, slot) VALUES (?, ?, ?) `); const insertAbility = this.db.prepare(` INSERT OR IGNORE INTO abilities (id, name) VALUES (?, ?) `); const insertPokemonAbility = this.db.prepare(` INSERT OR REPLACE INTO pokemon_abilities (pokemon_id, ability_id, is_hidden, slot) VALUES (?, ?, ?, ?) `); // Fetch detailed data for each Pokemon in parallel const pokemonPromises = pokemonList.map(async (pokemon) => { try { const pokemonId = parseInt(pokemon.url.split('/').slice(-2, -1)[0]); const detailData = (await this.fetchWithRetry( `${this.baseUrl}/pokemon/${pokemonId}` )) as Pokemon; console.log('DEBUG detailData:', detailData); // Get generation from species data if (!detailData.species || !detailData.species.url) { console.error( `❌ No species.url for ${pokemon.name}, detailData:`, detailData ); return null; } const speciesData = (await this.fetchWithRetry( detailData.species.url )) as SpeciesResponse; console.log('DEBUG speciesData:', speciesData); const generation = parseInt( speciesData.generation.url.split('/').slice(-2, -1)[0] ); return { id: pokemonId, name: detailData.name, height: detailData.height, weight: detailData.weight, base_experience: detailData.base_experience, generation, species_url: detailData.species?.url || '', sprite_url: detailData.sprites?.front_default || '', stats: detailData.stats, types: detailData.types, abilities: detailData.abilities, }; } catch (error) { console.error( `❌ Failed to fetch ${pokemon.name}: ${error instanceof Error ? error.message : String(error)}` ); return null; } }); const results = await Promise.all(pokemonPromises); const validResults = results.filter( (result): result is NonNullable<typeof result> => result !== null ); // Insert data in transaction for performance const transaction = this.db.transaction(() => { for (const pokemon of validResults) { // Insert Pokemon insertPokemon.run( pokemon.id, pokemon.name, pokemon.height ?? 0, pokemon.weight ?? 0, pokemon.base_experience ?? 0, pokemon.generation ?? 1, pokemon.species_url ?? '', pokemon.sprite_url ?? '' ); // Insert Stats for (const stat of pokemon.stats) { insertStat.run( pokemon.id, stat.stat.name, stat.base_stat ?? 0, stat.effort ?? 0 ); } // Insert Types for (const type of pokemon.types) { const typeId = parseInt(type.type.url.split('/').slice(-2, -1)[0]); insertPokemonType.run(pokemon.id, typeId, type.slot ?? 1); } // Insert Abilities for (const ability of pokemon.abilities) { const abilityId = parseInt( ability.ability.url.split('/').slice(-2, -1)[0] ); insertAbility.run(abilityId, ability.ability.name); insertPokemonAbility.run( pokemon.id, abilityId, ability.is_hidden ? 1 : 0, ability.slot ?? 1 ); } } }); transaction(); return validResults.length; } async ingestAllPokemon(limit: number | null = null): Promise<void> { console.log('πŸ”„ Starting Pokemon data ingestion...'); // First, ingest types await this.ingestTypes(); // Determine final limit with validation const requestedLimit = limit ?? this.config.maxPokemon; const finalLimit = Math.min(requestedLimit, this.config.maxPokemon); if (finalLimit !== requestedLimit) { console.log(`⚠️ Requested ${requestedLimit} Pokemon, but limited to ${finalLimit} by configuration`); } console.log(`πŸ“Š Ingesting ${finalLimit} Pokemon in batches of ${this.config.batchSize}`); const pokemonListUrl = `${this.baseUrl}/pokemon?limit=${finalLimit}`; const pokemonList = (await this.fetchWithRetry( pokemonListUrl )) as PokemonListResponse; // Process in batches with proper rate limiting const totalBatches = Math.ceil(pokemonList.results.length / this.config.batchSize); let processedCount = 0; for (let i = 0; i < pokemonList.results.length; i += this.config.batchSize) { const batchNum = Math.floor(i / this.config.batchSize) + 1; const batch = pokemonList.results.slice(i, i + this.config.batchSize); console.log(`πŸ”„ Processing batch ${batchNum}/${totalBatches} (${batch.length} Pokemon)...`); try { const count = await this.ingestPokemonBatch(batch); processedCount += count; const rateLimiterStats = this.rateLimiter.getStats(); console.log( `βœ… Batch ${batchNum}/${totalBatches}: ${count} Pokemon processed ` + `(Total: ${processedCount}/${pokemonList.results.length}, ` + `Requests: ${rateLimiterStats.requestCount})` ); // Rate limiting delay between batches if (i + this.config.batchSize < pokemonList.results.length) { console.log(`⏱️ Waiting ${this.config.batchDelayMs}ms before next batch...`); await this.delay(this.config.batchDelayMs); } } catch (error) { console.error(`❌ Batch ${batchNum} failed:`, error instanceof Error ? error.message : String(error)); console.log('⏭️ Continuing with next batch...'); } } console.log('βœ… Pokemon data ingestion complete'); } printStats(): void { interface Stats { count: number; } const stats = { pokemon: ( this.db.prepare('SELECT COUNT(*) as count FROM pokemon').get() as Stats ).count, types: ( this.db.prepare('SELECT COUNT(*) as count FROM types').get() as Stats ).count, abilities: ( this.db .prepare('SELECT COUNT(*) as count FROM abilities') .get() as Stats ).count, moves: ( this.db.prepare('SELECT COUNT(*) as count FROM moves').get() as Stats ).count, }; console.log('\nπŸ“Š Database Statistics:'); console.log(`Total Pokemon: ${stats.pokemon}`); console.log(`Total Types: ${stats.types}`); console.log(`Total Abilities: ${stats.abilities}`); console.log(`Total Moves: ${stats.moves}`); } getPokemonByType(typeName: string): Array<{ id: number; name: string }> { return this.db .prepare( ` SELECT p.id, p.name FROM pokemon p JOIN pokemon_types pt ON p.id = pt.pokemon_id JOIN types t ON pt.type_id = t.id WHERE LOWER(t.name) = LOWER(?) ORDER BY p.id ` ) .all(typeName) as Array<{ id: number; name: string }>; } getPokemonStats( pokemonName: string ): Array<{ stat_name: string; base_stat: number }> { return this.db .prepare( ` SELECT s.stat_name, s.base_stat FROM stats s JOIN pokemon p ON s.pokemon_id = p.id WHERE LOWER(p.name) = LOWER(?) ORDER BY s.stat_name ` ) .all(pokemonName) as Array<{ stat_name: string; base_stat: number }>; } close(): void { this.db.close(); } } async function main(): Promise<void> { const ingestion = new PokemonDataIngestion(); try { await ingestion.ingestAllPokemon(); ingestion.printStats(); } catch (error) { console.error( '❌ Error during ingestion:', error instanceof Error ? error.message : String(error) ); } finally { ingestion.close(); } } // Use ES modules check instead of CommonJS if (import.meta.url === `file://${process.argv[1]}`) { main().catch(console.error); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/grovesjosephn/pokemcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server