Scryfall MCP Server

#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { CallToolRequestSchema, ListToolsRequestSchema, Tool } from "@modelcontextprotocol/sdk/types.js"; import fetch, { Response } from "node-fetch"; import express from "express"; import yargs from "yargs"; import { hideBin } from "yargs/helpers"; import { createServer, IncomingMessage, ServerResponse } from "node:http"; import { parse } from "node:url"; /** * Scryfall API references: * - https://api.scryfall.com * - https://scryfall.com/docs/api * * The server below exposes several tools: * 1) search_cards - Perform a text query and list matching cards * 2) get_card_by_id - Get a card by Scryfall ID (UUID) * 3) get_card_by_name - Get a card by exact name * 4) random_card - Get a random card * 5) get_rulings - Retrieve rulings (official text on card interactions) by card ID * 6) get_prices - Get card prices for a specified card ID or exact name * * Each tool returns data in JSON format as a single text field. */ interface ScryfallError { object: string; // "error" code: string; // "not_found", etc. status: number; // HTTP status code details: string; // Description type?: string; // "ambiguous", etc. warnings?: string[]; } // Scryfall Card object (abbreviated shape) interface ScryfallCard { object: "card"; id: string; // Scryfall ID name: string; mana_cost: string; type_line: string; oracle_text: string; set: string; set_name: string; collector_number: string; // More fields omitted; see https://scryfall.com/docs/api/cards prices: { usd?: string | null; usd_foil?: string | null; eur?: string | null; tix?: string | null; }; } // Scryfall Ruling object interface ScryfallRuling { object: "ruling"; source: string; published_at: string; comment: string; } // Tools definitions const SEARCH_CARDS_TOOL: Tool = { name: "search_cards", description: "Search for MTG cards by a text query, e.g. 'oracle text includes: draw cards'. " + "Returns a list of matching cards (with basic fields: name, set, collector_number, ID). " + "If no matches are found, returns an error message from Scryfall.", inputSchema: { type: "object", properties: { query: { type: "string", description: "A full text query, e.g. 't:goblin pow=2 o:haste'" } }, required: ["query"] } }; const GET_CARD_BY_ID_TOOL: Tool = { name: "get_card_by_id", description: "Retrieve a card by its Scryfall ID (a 36-char UUID). Returns the card data in JSON.", inputSchema: { type: "object", properties: { id: { type: "string", description: "The Scryfall UUID, e.g. 'c09c71fb-7acb-4ffb-a47b-8961a0cf4990'" } }, required: ["id"] } }; const GET_CARD_BY_NAME_TOOL: Tool = { name: "get_card_by_name", description: "Retrieve a card by its exact English name, e.g. 'Black Lotus'. Returns the card data in JSON. " + "If multiple cards share that exact name, Scryfall returns one (usually the most relevant printing).", inputSchema: { type: "object", properties: { name: { type: "string", description: "Exact name of the card, e.g. 'Lightning Bolt'" } }, required: ["name"] } }; const RANDOM_CARD_TOOL: Tool = { name: "random_card", description: "Retrieve a random Magic card from Scryfall. Returns JSON data for that random card.", inputSchema: { type: "object", properties: {}, required: [] } }; const GET_RULINGS_TOOL: Tool = { name: "get_rulings", description: "Retrieve official rulings for a specified card by Scryfall ID or Oracle ID. " + "Returns an array of rulings. Each ruling has a 'published_at' date and a 'comment' field.", inputSchema: { type: "object", properties: { id: { type: "string", description: "A Scryfall ID or Oracle ID. Example: 'c09c71fb-7acb-4ffb-a47b-8961a0cf4990'" } }, required: ["id"] } }; const GET_PRICES_BY_ID_TOOL: Tool = { name: "get_prices_by_id", description: "Retrieve price information for a card by its Scryfall ID. Returns JSON with usd, usd_foil, eur, tix, etc.", inputSchema: { type: "object", properties: { id: { type: "string", description: "Scryfall ID of the card" } }, required: ["id"] } }; const GET_PRICES_BY_NAME_TOOL: Tool = { name: "get_prices_by_name", description: "Retrieve price information for a card by its exact name. Returns JSON with usd, usd_foil, eur, tix, etc.", inputSchema: { type: "object", properties: { name: { type: "string", description: "Exact card name" } }, required: ["name"] } }; // Return our set of tools const SCRYFALL_TOOLS = [ SEARCH_CARDS_TOOL, GET_CARD_BY_ID_TOOL, GET_CARD_BY_NAME_TOOL, RANDOM_CARD_TOOL, GET_RULINGS_TOOL, GET_PRICES_BY_ID_TOOL, GET_PRICES_BY_NAME_TOOL ] as const; // Helper to handle Scryfall responses async function handleScryfallResponse(response: Response) { if (!response.ok) { // Attempt to parse Scryfall error let errorObj: ScryfallError | null = null; try { errorObj = (await response.json()) as ScryfallError; } catch { // fall back to generic } if (errorObj && errorObj.object === "error") { return { content: [ { type: "text", text: `Scryfall error: ${errorObj.details} (code=${errorObj.code}, status=${errorObj.status})` } ], isError: true }; } else { return { content: [ { type: "text", text: `HTTP error ${response.status}: ${response.statusText}` } ], isError: true }; } } // If okay, parse JSON const data = await response.json(); return { content: [ { type: "text", text: JSON.stringify(data, null, 2) } ], isError: false }; } // Actual call handlers async function handleSearchCards(query: string) { const url = `https://api.scryfall.com/cards/search?q=${encodeURIComponent( query )}`; const response = await fetch(url); return handleScryfallResponse(response); } async function handleGetCardById(id: string) { const url = `https://api.scryfall.com/cards/${encodeURIComponent(id)}`; const response = await fetch(url); return handleScryfallResponse(response); } async function handleGetCardByName(name: string) { // Tilde in URL means 'exact' mode for the card name const url = `https://api.scryfall.com/cards/named?exact=${encodeURIComponent( name )}`; const response = await fetch(url); return handleScryfallResponse(response); } async function handleRandomCard() { const url = "https://api.scryfall.com/cards/random"; const response = await fetch(url); return handleScryfallResponse(response); } async function handleGetRulings(id: string) { // Scryfall docs: /cards/{id}/rulings // Also works with /cards/{oracle_id}/rulings const url = `https://api.scryfall.com/cards/${encodeURIComponent( id )}/rulings`; const response = await fetch(url); return handleScryfallResponse(response); } async function handleGetPricesById(id: string) { const url = `https://api.scryfall.com/cards/${encodeURIComponent(id)}`; const response = await fetch(url); if (!response.ok) { return handleScryfallResponse(response); } const data = (await response.json()) as ScryfallCard; if (!data.prices) { return { content: [ { type: "text", text: "No price information found for this card." } ], isError: false }; } return { content: [ { type: "text", text: JSON.stringify(data.prices, null, 2) } ], isError: false }; } async function handleGetPricesByName(name: string) { const url = `https://api.scryfall.com/cards/named?exact=${encodeURIComponent( name )}`; const response = await fetch(url); if (!response.ok) { return handleScryfallResponse(response); } const data = (await response.json()) as ScryfallCard; if (!data.prices) { return { content: [ { type: "text", text: "No price information found for this card." } ], isError: false }; } return { content: [ { type: "text", text: JSON.stringify(data.prices, null, 2) } ], isError: false }; } // A map of sessionId -> { transport, server } for SSE connections const transportsBySession = new Map< string, { transport: SSEServerTransport; server: Server } >(); // Create a new server instance with all our handlers function createScryfallServer() { const newServer = new Server( { name: "mcp-server/scryfall", version: "0.1.0" }, { capabilities: { tools: {} } } ); // Set up our request handlers newServer.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: SCRYFALL_TOOLS })); newServer.setRequestHandler(CallToolRequestSchema, async (request) => { try { const { name, arguments: args } = request.params; switch (name) { case "search_cards": { const { query } = args as { query: string }; return await handleSearchCards(query); } case "get_card_by_id": { const { id } = args as { id: string }; return await handleGetCardById(id); } case "get_card_by_name": { const { name } = args as { name: string }; return await handleGetCardByName(name); } case "random_card": { return await handleRandomCard(); } case "get_rulings": { const { id } = args as { id: string }; return await handleGetRulings(id); } case "get_prices_by_id": { const { id } = args as { id: string }; return await handleGetPricesById(id); } case "get_prices_by_name": { const { name } = args as { name: string }; return await handleGetPricesByName(name); } default: return { content: [ { type: "text", text: `Error: Unknown tool name "${name}"` } ], isError: true }; } } catch (err) { return { content: [ { type: "text", text: `Error: ${(err as Error).message}` } ], isError: true }; } }); return newServer; } // Start the server with either stdio or SSE transport async function runServer() { const argv = await yargs(hideBin(process.argv)) .option("sse", { type: "boolean", description: "Use SSE transport instead of stdio", default: false }) .option("port", { type: "number", description: "Port to use for SSE transport", default: 3000 }) .help().argv; if (argv.sse) { const httpServer = createServer( async (req: IncomingMessage, res: ServerResponse): Promise<void> => { const url = parse(req.url ?? "", true); if (req.method === "GET" && url.pathname === "/sse") { // Client establishing SSE connection const transport = new SSEServerTransport("/messages", res); const scryfallServer = createScryfallServer(); // Store them in our map for routing POSTs transportsBySession.set(transport.sessionId, { transport, server: scryfallServer }); // Set SSE headers res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); // Connect transport to server scryfallServer.connect(transport).catch((err) => { console.error("Error attaching SSE transport:", err); res.end(); }); console.error( `New SSE connection established (session: ${transport.sessionId})` ); // Return here - the response will be kept open for SSE return; } else if (req.method === "POST" && url.pathname === "/messages") { // Client sending an MCP message over POST const sessionId = url.query.sessionId as string; const record = transportsBySession.get(sessionId); if (!record) { res.writeHead(404, "Unknown session"); res.end(); return; } // Forward the POST body to this session's transport await record.transport.handlePostMessage(req, res); return; } else { res.writeHead(404, "Not Found"); res.end(); return; } } ); httpServer.listen(argv.port, () => { console.error( `Scryfall MCP Server listening on http://localhost:${argv.port}` ); }); } else { // Standard stdio mode const server = createScryfallServer(); const transport = new StdioServerTransport(); await server.connect(transport); console.error("Scryfall MCP Server running on stdio"); } } runServer().catch((error) => { console.error("Fatal error running Scryfall server:", error); process.exit(1); });