Skip to main content
Glama

Scryfall MCP Server

by bmurdock
scryfall-client.ts14 kB
// HTTP client for Scryfall API import { ScryfallCard, ScryfallSearchResponse, ScryfallSet, ScryfallError, BulkDataInfo, ScryfallListResponse, } from "../types/scryfall-api.js"; import { REQUIRED_HEADERS, ScryfallAPIError, RateLimitError } from "../types/mcp-types.js"; import { RateLimiter } from "./rate-limiter.js"; import { CacheService } from "./cache-service.js"; import { mcpLogger } from "./logger.js"; import { generateRequestId } from "../types/mcp-errors.js"; import { EnvValidators } from "../utils/env-parser.js"; /** * HTTP client for Scryfall API with rate limiting and caching */ export class ScryfallClient { private readonly baseUrl = "https://api.scryfall.com"; private rateLimiter: RateLimiter; private cache: CacheService; constructor( rateLimiter?: RateLimiter, cache?: CacheService, private userAgent: string = EnvValidators.userAgent(process.env.SCRYFALL_USER_AGENT) ) { this.rateLimiter = rateLimiter || new RateLimiter(); this.cache = cache || new CacheService(); } /** * Makes a rate-limited HTTP request to Scryfall API with structured logging */ private async makeRequest<T = unknown>(url: string, requestId?: string): Promise<T> { const reqId = requestId || generateRequestId(); const startTime = Date.now(); mcpLogger.debug( { requestId: reqId, url, operation: "scryfall_request" }, "Scryfall API request started" ); // Check circuit breaker if (this.rateLimiter.isCircuitOpen()) { const error = new ScryfallAPIError( "Circuit breaker is open due to consecutive failures", 503, "circuit_breaker_open" ); mcpLogger.error( { requestId: reqId, url, error, operation: "scryfall_request", }, "Circuit breaker is open" ); throw error; } // Wait for rate limit clearance await this.rateLimiter.waitForClearance(); try { // Timeout support via AbortController const timeoutMs = EnvValidators.scryfallTimeoutMs(process.env.SCRYFALL_TIMEOUT_MS); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); const response = await fetch(url, { headers: { ...REQUIRED_HEADERS, "User-Agent": this.userAgent, }, signal: controller.signal, }).finally(() => clearTimeout(timeout)); // Handle the response body properly let data; let responseText: string | null = null; try { // First try to get the response text for debugging purposes const clonedResponse = response.clone(); responseText = await clonedResponse.text(); // Then parse as JSON data = JSON.parse(responseText); } catch (jsonError) { const originalError = jsonError instanceof Error ? jsonError.message : "Unknown JSON error"; const debugText = responseText ? responseText.substring(0, 200) : "Unable to read response"; throw new Error( `Failed to parse JSON response. Original error: ${originalError}. Response preview: ${debugText}...` ); } if (response.status >= 400) { this.rateLimiter.recordError(response.status); if (response.status === 429) { const retryAfter = response.headers.get("retry-after"); await this.rateLimiter.handleRateLimitResponse(retryAfter || undefined); const rateLimitError = new RateLimitError( "Rate limit exceeded", retryAfter ? parseInt(retryAfter) : undefined ); mcpLogger.warn( { requestId: reqId, url, status: response.status, retryAfter, operation: "scryfall_request", }, "Rate limit exceeded" ); throw rateLimitError; } const error = data as ScryfallError; const apiError = new ScryfallAPIError( error.details || `HTTP ${response.status}`, response.status, error.code, error.details ); mcpLogger.error( { requestId: reqId, url, status: response.status, error: apiError, operation: "scryfall_request", }, "Scryfall API error" ); throw apiError; } this.rateLimiter.recordSuccess(); const duration = Date.now() - startTime; mcpLogger.debug( { requestId: reqId, url, status: response.status, duration, operation: "scryfall_request", }, "Scryfall API request completed" ); return data; } catch (error) { if (error instanceof ScryfallAPIError || error instanceof RateLimitError) { throw error; } this.rateLimiter.recordError(); const isAbort = error instanceof Error && error.name === 'AbortError'; const networkError = new ScryfallAPIError( `Network error: ${error instanceof Error ? error.message : "Unknown error"}`, 0, isAbort ? "timeout" : "network_error" ); mcpLogger.error( { requestId: reqId, url, error: networkError, originalError: error instanceof Error ? error.message : String(error), operation: "scryfall_request", }, "Network error during Scryfall request" ); throw networkError; } } /** * Searches for cards using Scryfall search syntax */ async searchCards( params: { query: string; limit?: number; page?: number; include_extras?: boolean; order?: string; unique?: string; direction?: string; include_multilingual?: boolean; include_variations?: boolean; price_range?: { min?: number; max?: number; currency?: string; }; }, requestId?: string ): Promise<ScryfallSearchResponse> { const reqId = requestId || generateRequestId(); const cacheKey = CacheService.createSearchKey( params.query, params.page || 1, params.limit || 20, params.unique, params.direction, params.include_multilingual, params.include_variations, params.price_range ); mcpLogger.debug( { requestId: reqId, operation: "search_cards", query: params.query }, "Card search started" ); // Check cache first const cached = this.cache.getWithStats<ScryfallSearchResponse>(cacheKey); if (cached) { mcpLogger.debug( { requestId: reqId, operation: "search_cards", cacheHit: true }, "Card search cache hit" ); return cached; } const url = new URL(`${this.baseUrl}/cards/search`); // Build query with price filtering if specified let query = params.query; if (params.price_range) { const currency = params.price_range.currency || "usd"; if (params.price_range.min !== undefined) { query += ` ${currency}>=${params.price_range.min}`; } if (params.price_range.max !== undefined) { query += ` ${currency}<=${params.price_range.max}`; } } url.searchParams.set("q", query); // Note: Scryfall does not support custom page size on search; we slice in formatters if (params.page && params.page > 1) { url.searchParams.set("page", params.page.toString()); } if (params.include_extras) { url.searchParams.set("include_extras", "true"); } if (params.include_multilingual) { url.searchParams.set("include_multilingual", "true"); } if (params.include_variations) { url.searchParams.set("include_variations", "true"); } if (params.order) { url.searchParams.set("order", params.order); } if (params.unique && params.unique !== "cards") { url.searchParams.set("unique", params.unique); } if (params.direction && params.direction !== "auto") { url.searchParams.set("dir", params.direction); } const data = await this.makeRequest<ScryfallSearchResponse>(url.toString(), reqId); // Cache the result this.cache.setWithType(cacheKey, data, "card_search"); mcpLogger.debug( { requestId: reqId, operation: "search_cards", resultCount: data.data?.length }, "Card search completed" ); return data; } /** * Gets a card by name, set code+number, or Scryfall ID */ async getCard(params: { identifier: string; set?: string; lang?: string; face?: "front" | "back"; }): Promise<ScryfallCard> { const cacheKey = CacheService.createCardKey(params.identifier, params.set, params.lang || "en"); // Check cache first const cached = this.cache.getWithStats<ScryfallCard>(cacheKey); if (cached) { return cached; } let url: URL; // Check if identifier is a UUID (Scryfall ID) const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; if (uuidRegex.test(params.identifier)) { url = new URL(`${this.baseUrl}/cards/${params.identifier}`); } // Check if identifier is set code + collector number else if (params.identifier.includes("/")) { const [setCode, collectorNumber] = params.identifier.split("/"); url = new URL(`${this.baseUrl}/cards/${setCode}/${collectorNumber}`); } // Otherwise treat as card name else { url = new URL(`${this.baseUrl}/cards/named`); url.searchParams.set("fuzzy", params.identifier); if (params.set) { url.searchParams.set("set", params.set); } } if (params.lang && params.lang !== "en") { url.searchParams.set("lang", params.lang); } const data = await this.makeRequest<ScryfallCard>(url.toString()); // Cache the result this.cache.setWithType(cacheKey, data, "card_details"); return data; } /** * Gets a random card with optional filters */ async getRandomCard(query?: string): Promise<ScryfallCard> { const url = new URL(`${this.baseUrl}/cards/random`); if (query) { url.searchParams.set("q", query); } // Don't cache random cards as they should be different each time return this.makeRequest<ScryfallCard>(url.toString()); } /** * Gets all Magic sets */ async getSets(params?: { query?: string; type?: string; released_after?: string; released_before?: string; }): Promise<ScryfallSet[]> { const cacheKey = CacheService.createSetKey(params?.query, params?.type); // Check cache first const cached = this.cache.getWithStats<{ data: ScryfallSet[] }>(cacheKey); if (cached) { return cached.data; } const url = new URL(`${this.baseUrl}/sets`); // Note: Scryfall doesn't support query/type filtering on sets endpoint // We'll filter client-side if needed const data = await this.makeRequest<ScryfallListResponse<ScryfallSet>>(url.toString()); let sets = data.data; // Apply client-side filtering if (params?.query) { const query = params.query.toLowerCase(); sets = sets.filter( (set) => set.name.toLowerCase().includes(query) || set.code.toLowerCase().includes(query) ); } if (params?.type) { sets = sets.filter((set) => set.set_type === params.type); } if (params?.released_after) { const afterDate = new Date(params.released_after); sets = sets.filter((set) => set.released_at && new Date(set.released_at) >= afterDate); } if (params?.released_before) { const beforeDate = new Date(params.released_before); sets = sets.filter((set) => set.released_at && new Date(set.released_at) <= beforeDate); } // Cache the filtered result this.cache.setWithType(cacheKey, { data: sets }, "set_data"); return sets; } /** * Gets bulk card data information */ async getBulkDataInfo(): Promise<BulkDataInfo[]> { const cacheKey = CacheService.createBulkKey("info"); // Check cache first const cached = this.cache.getWithStats<{ data: BulkDataInfo[] }>(cacheKey); if (cached) { return cached.data; } const url = `${this.baseUrl}/bulk-data`; const data = await this.makeRequest<ScryfallListResponse<BulkDataInfo>>(url); // Cache bulk data info this.cache.setWithType(cacheKey, data, "bulk_data"); return data.data; } /** * Downloads bulk card data (doesn't count against rate limits) */ async downloadBulkData(downloadUri: string): Promise<ScryfallCard[]> { try { const timeoutMs = EnvValidators.scryfallTimeoutMs(process.env.SCRYFALL_TIMEOUT_MS) * 2; // Double timeout for bulk downloads const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); const response = await fetch(downloadUri, { headers: { "User-Agent": this.userAgent, }, signal: controller.signal, }).finally(() => clearTimeout(timeout)); if (response.status >= 400) { throw new ScryfallAPIError( `Failed to download bulk data: HTTP ${response.status}`, response.status ); } return (await response.json()) as ScryfallCard[]; } catch (error) { throw new ScryfallAPIError( `Bulk data download error: ${error instanceof Error ? error.message : "Unknown error"}`, 0, (error instanceof Error && error.name === 'AbortError') ? "timeout" : "bulk_download_error" ); } } /** * Clears all cached data */ clearCache(): void { this.cache.clear(); } /** * Resets rate limiter state */ resetRateLimiter(): void { this.rateLimiter.reset(); } /** * Cleanup resources */ destroy(): void { this.cache.destroy(); this.rateLimiter.reset(); } }

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/bmurdock/scryfall-mcp'

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