Skip to main content
Glama
mcp-server.ts25.2 kB
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { Scryfall, type SearchParams } from "./scryfall.js"; import { fetchTaggerTags, toKebabTag, readTaggerCache, refreshTaggerTags } from "./tags.js"; import { CSB } from "./csb.js"; import { loadCsbIndex, writeCsbIndex, readCsbIndex, buildCsbIndex, lookupCsbIdsByOracle } from "./csb-index.js"; type JsonContent = { type: "json"; json: unknown }; type ToolResult = { content: JsonContent[] }; export function createMcpServer(): any { const server: any = new McpServer( { name: "scryfall-mcp", version: "0.1.0" }, { capabilities: { tools: {} } } ); // Helper: build Scryfall query strings safely const quote = (v: string): string => /\s|\"|:/.test(v) ? `"${v.replaceAll("\"", '\\"')}"` : v; const joinParts = (parts: Array<string | undefined>): string => parts.filter(Boolean).join(" "); const cardSummaryShape = { name: z.string(), mana_cost: z.string().optional(), type_line: z.string(), oracle_text: z.string().optional(), set: z.string(), collector_number: z.string(), scryfall_uri: z.string().url(), image: z.string().url().optional(), prices: z .object({ usd: z.string().nullable().optional(), eur: z.string().nullable().optional(), tix: z.string().nullable().optional() }) .partial() .optional() } as const; type CardSummary = z.infer<z.ZodObject<typeof cardSummaryShape>>; const summarize = (card: any): CardSummary => ({ name: card?.name, mana_cost: card?.mana_cost, type_line: card?.type_line, oracle_text: card?.oracle_text, set: card?.set, collector_number: String(card?.collector_number ?? ""), scryfall_uri: card?.scryfall_uri, image: card?.image_uris?.normal ?? card?.image_uris?.large ?? card?.image_uris?.small, prices: card?.prices }); // Tool: search_cards // Zod shapes for MCPServer (use raw shapes, not ZodObject) const searchParamsShape = { q: z.string().describe("Scryfall search query, e.g., 't:creature cmc<=3'"), unique: z.enum(["cards", "art", "prints"]).optional(), order: z .enum([ "name", "set", "released", "rarity", "color", "usd", "tix", "eur", "cmc", "power", "toughness", "edhrec", "penny", "artist", "review" ]) .optional(), dir: z.enum(["auto", "asc", "desc"]).optional(), page: z.number().int().min(1).optional(), include_extras: z.boolean().optional(), include_multilingual: z.boolean().optional(), include_variations: z.boolean().optional() } as const; server.registerTool( "search_cards", { description: "Search cards using Scryfall's powerful full-text syntax.", inputSchema: searchParamsShape }, async (params: SearchParams): Promise<ToolResult> => { const data: unknown = await Scryfall.searchCards(params); return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] } as any; } ); // Tool: search_by_colors const searchByColorsInput = { colors: z.array(z.enum(["W", "U", "B", "R", "G"])).min(0).max(5).optional(), mode: z.enum(["exact", "contains", "at_most"]).default("contains"), include_colorless: z.boolean().optional().describe("Include colorless cards"), identity: z.boolean().optional().describe("Filter by color identity instead of printed colors"), page: z.number().int().min(1).optional() } as const; const searchByColorsOutput = { total: z.number().int().nonnegative(), results: z.array(z.object(cardSummaryShape)) } as const; server.registerTool( "search_by_colors", { title: "Search by colors", description: "Find cards by colors or color identity.", inputSchema: searchByColorsInput, outputSchema: searchByColorsOutput }, async ({ colors = [], mode, include_colorless, identity, page }: { colors?: Array<"W" | "U" | "B" | "R" | "G">; mode: "exact" | "contains" | "at_most"; include_colorless?: boolean; identity?: boolean; page?: number }) => { const op = mode === "exact" ? "=" : mode === "contains" ? ">=" : "<="; const opKey = identity ? "c" : "color"; // c = color identity const parts = [colors.length ? `${opKey}${op}${colors.join("")}` : undefined, include_colorless ? "is:colorless" : undefined]; const q = joinParts(parts); const data: any = (await Scryfall.searchCards({ q, page })) as any; const items: any[] = Array.isArray(data?.data) ? data.data : []; const out = { total: Number(data?.total_cards ?? items.length), results: items.map(summarize) }; return { structuredContent: out } as any; } ); // Tool: search_by_cmc (mana value) const searchByCmcInput = { min: z.number().int().min(0).optional(), max: z.number().int().min(0).optional(), colors: z.array(z.enum(["W", "U", "B", "R", "G"])).min(0).max(5).optional(), type: z.string().optional(), page: z.number().int().min(1).optional() } as const; const searchByCmcOutput = searchByColorsOutput; server.registerTool( "search_by_cmc", { title: "Search by mana value", description: "Find cards within a mana value range, optionally filtered by color and type.", inputSchema: searchByCmcInput, outputSchema: searchByCmcOutput }, async ({ min, max, colors = [], type, page }: { min?: number; max?: number; colors?: Array<"W" | "U" | "B" | "R" | "G">; type?: string; page?: number }) => { const range = [typeof min === "number" ? `mv>=${min}` : undefined, typeof max === "number" ? `mv<=${max}` : undefined]; const colorPart = colors.length ? `color>=${colors.join("")}` : undefined; const typePart = type ? `type:${quote(type)}` : undefined; const q = joinParts([...range, colorPart, typePart]); const data: any = (await Scryfall.searchCards({ q, page })) as any; const items: any[] = Array.isArray(data?.data) ? data.data : []; const out = { total: Number(data?.total_cards ?? items.length), results: items.map(summarize) }; return { structuredContent: out } as any; } ); // Tool: search_by_format legality const formats = [ "standard", "pioneer", "modern", "legacy", "vintage", "commander", "oathbreaker", "pauper", "paupercommander", "historic", "timeless", "alchemy", "brawl", "duel", "predh" ] as const; const searchByFormatInput = { format: z.enum(formats), status: z.enum(["legal", "banned", "restricted"]).default("legal"), colors: z.array(z.enum(["W", "U", "B", "R", "G"])).min(0).max(5).optional(), page: z.number().int().min(1).optional() } as const; server.registerTool( "search_by_format", { title: "Search by format legality", description: "Find cards by legality in a given format.", inputSchema: searchByFormatInput, outputSchema: searchByColorsOutput }, async ({ format, status, colors = [], page }: { format: typeof formats[number]; status: "legal" | "banned" | "restricted"; colors?: Array<"W" | "U" | "B" | "R" | "G">; page?: number }) => { const legal = `${status}:${format}`; // e.g., legal:commander const colorPart = colors.length ? `color>=${colors.join("")}` : undefined; const q = joinParts([legal, colorPart]); const data: any = (await Scryfall.searchCards({ q, page })) as any; const items: any[] = Array.isArray(data?.data) ? data.data : []; const out = { total: Number(data?.total_cards ?? items.length), results: items.map(summarize) }; return { structuredContent: out } as any; } ); // Tool: get_card (by id or name) const getCardParamsShape = { id: z.string().uuid().optional(), name: z.string().optional(), fuzzy: z.boolean().optional().describe("If true, uses fuzzy name match") } as const; server.registerTool( "get_card", { description: "Get a single card by Scryfall UUID or by name (exact/fuzzy).", inputSchema: getCardParamsShape }, async ({ id, name, fuzzy }: { id?: string; name?: string; fuzzy?: boolean }): Promise<ToolResult> => { const data: unknown = id ? await Scryfall.getCardById(id) : await Scryfall.getCardNamed(name as string, Boolean(fuzzy)); return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] } as any; } ); // Tool: random_card const randomCardParamsShape = { q: z.string().optional() } as const; server.registerTool( "random_card", { description: "Fetch a random card, optionally filtered by a 'q' search query.", inputSchema: randomCardParamsShape }, async ({ q }: { q?: string }): Promise<ToolResult> => { const data: unknown = await Scryfall.getRandomCard(q); return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] } as any; } ); // Tool: autocomplete (card names) const autocompleteParamsShape = { q: z.string() } as const; server.registerTool( "autocomplete", { description: "Autocomplete card names based on a partial query.", inputSchema: autocompleteParamsShape }, async ({ q }: { q: string }): Promise<ToolResult> => { const data: unknown = await Scryfall.autocomplete(q); return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] } as any; } ); // Tool: list_sets server.registerTool( "list_sets", { description: "List all sets available on Scryfall.", // No input schema for zero-arg tools } as any, async (): Promise<ToolResult> => { const data: unknown = await Scryfall.listSets(); return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] } as any; } ); // Tool: get_rulings const getRulingsParamsShape = { id: z.string().uuid() } as const; server.registerTool( "get_rulings", { description: "Get official rulings for a card by Scryfall UUID.", inputSchema: getRulingsParamsShape }, async ({ id }: { id: string }): Promise<ToolResult> => { const data: unknown = await Scryfall.getRulingsById(id); return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] } as any; } ); // Tool: list_tagger_tags (scrapes public docs) const listTaggerTagsOutput = { function: z.array(z.string()), art: z.array(z.string()) } as const; server.registerTool( "list_tagger_tags", { title: "List Tagger tags", description: "Fetches Scryfall Tagger tag names from the public docs page.", outputSchema: listTaggerTagsOutput }, async () => { const tags = await fetchTaggerTags(); return { structuredContent: tags } as any; } ); // Tool: read_tagger_cache (no network) const readTaggerCacheOutput = { function: z.array(z.string()), art: z.array(z.string()), cachedAtMs: z.number(), cachePath: z.string() } as const; server.registerTool( "read_tagger_cache", { title: "Read Tagger cache", description: "Read tags from local cache file without network.", outputSchema: readTaggerCacheOutput }, async () => { const cached = await readTaggerCache(); if (!cached) { return { content: [{ type: "text", text: "No cache found" }] } as any; } return { structuredContent: { function: cached.data.function, art: cached.data.art, cachedAtMs: cached.at, cachePath: process.env.TAGGER_CACHE_PATH || require('node:path').join(process.cwd(), 'cache', 'tagger-tags.json') } } as any; } ); // Tool: refresh_tagger_tags (force network + write cache) const refreshTaggerTagsOutput = { path: z.string(), counts: z.object({ function: z.number().int().nonnegative(), art: z.number().int().nonnegative() }) } as const; server.registerTool( "refresh_tagger_tags", { title: "Refresh Tagger tags", description: "Force refresh Tagger tags from docs and write local cache.", outputSchema: refreshTaggerTagsOutput }, async () => { const res = await refreshTaggerTags(); return { structuredContent: res } as any; } ); // Tool: search_by_function_tag const searchByFuncTagInput = { tags: z.array(z.string()).min(1), match: z.enum(["any", "all"]).default("any"), colors: z.array(z.enum(["W", "U", "B", "R", "G"])).min(0).max(5).optional(), format: z .enum([ "standard", "pioneer", "modern", "legacy", "vintage", "commander", "oathbreaker", "pauper", "paupercommander", "historic", "timeless", "alchemy", "brawl", "duel", "predh" ]) .optional(), page: z.number().int().min(1).optional() } as const; server.registerTool( "search_by_function_tag", { title: "Search by function tag", description: "Find cards using Tagger function (oracle) tags, e.g. removal, ramp.", inputSchema: searchByFuncTagInput, outputSchema: searchByColorsOutput }, async ({ tags, match, colors = [], format, page }: { tags: string[]; match: "any" | "all"; colors?: Array<"W" | "U" | "B" | "R" | "G">; format?: string; page?: number }) => { const terms = tags.map((t) => `otag:${toKebabTag(t)}`); const joined = match === "all" ? terms.join(" ") : terms.join(" OR "); const colorPart = colors.length ? `color>=${colors.join("")}` : undefined; const formatPart = format ? `legal:${format}` : undefined; const q = joinParts([joined, colorPart, formatPart]); const data: any = (await Scryfall.searchCards({ q, page })) as any; const items: any[] = Array.isArray(data?.data) ? data.data : []; const out = { total: Number(data?.total_cards ?? items.length), results: items.map(summarize) }; return { structuredContent: out } as any; } ); // Tool: search_by_art_tag const searchByArtTagInput = { tags: z.array(z.string()).min(1), match: z.enum(["any", "all"]).default("any"), colors: z.array(z.enum(["W", "U", "B", "R", "G"])).min(0).max(5).optional(), type: z.string().optional(), page: z.number().int().min(1).optional() } as const; server.registerTool( "search_by_art_tag", { title: "Search by art tag", description: "Find cards using Tagger artwork tags, e.g. squirrel, dragon, wizard.", inputSchema: searchByArtTagInput, outputSchema: searchByColorsOutput }, async ({ tags, match, colors = [], type, page }: { tags: string[]; match: "any" | "all"; colors?: Array<"W" | "U" | "B" | "R" | "G">; type?: string; page?: number }) => { const terms = tags.map((t) => `arttag:${toKebabTag(t)}`); const joined = match === "all" ? terms.join(" ") : terms.join(" OR "); const colorPart = colors.length ? `color>=${colors.join("")}` : undefined; const typePart = type ? `type:${quote(type)}` : undefined; const q = joinParts([joined, colorPart, typePart]); const data: any = (await Scryfall.searchCards({ q, page })) as any; const items: any[] = Array.isArray(data?.data) ? data.data : []; const out = { total: Number(data?.total_cards ?? items.length), results: items.map(summarize) }; return { structuredContent: out } as any; } ); // Commander Spellbook tools // csb_parse_deck_text const csbParseDeckInput = { text: z.string().min(1).describe("Plain-text deck list, e.g. '1x Sol Ring' per line") } as const; const csbParseDeckOutput = { main: z.array(z.object({ card: z.string(), quantity: z.number().int().positive() })), commanders: z.array(z.object({ card: z.string(), quantity: z.number().int().positive() })).optional() } as const; server.registerTool( "csb_parse_deck_text", { title: "CSB: Parse deck text", description: "Parse a plain-text decklist into cards using Commander Spellbook.", inputSchema: csbParseDeckInput, outputSchema: csbParseDeckOutput }, async ({ text }: { text: string }) => { const res = (await CSB.parseCardListFromText(text)) as any; return { structuredContent: res } as any; } ); // csb_find_combos_by_card_ids const csbFindCombosInput = { ids: z.array(z.number().int().nonnegative()).min(1).describe("Commander Spellbook numeric card IDs"), limit: z.number().int().min(1).max(100).optional(), offset: z.number().int().min(0).optional() } as const; server.registerTool( "csb_find_combos_by_card_ids", { title: "CSB: Find combos by card IDs", description: "Find combos that the provided cards enable (exact and almost-included).", inputSchema: csbFindCombosInput }, async ({ ids, limit, offset }: { ids: number[]; limit?: number; offset?: number }) => { const res = await CSB.findMyCombos(ids, limit, offset); return { structuredContent: res } as any; } ); // csb_variants_search const csbVariantsSearchInput = { uses: z.number().int().nonnegative().optional().describe("Filter variants that use this CSB card ID"), produces: z.number().int().nonnegative().optional().describe("Filter variants that produce this feature ID (e.g., Win the game = 2)"), of: z.number().int().nonnegative().optional().describe("Filter variants that are of a specific combo ID group"), limit: z.number().int().min(1).max(100).optional(), offset: z.number().int().min(0).optional() } as const; server.registerTool( "csb_variants_search", { title: "CSB: Search variants", description: "Search Commander Spellbook variants (combos) by filters like uses/produces.", inputSchema: csbVariantsSearchInput }, async ({ uses, produces, of, limit, offset }: { uses?: number; produces?: number; of?: number; limit?: number; offset?: number }) => { const res = await CSB.variants({ uses, produces, of, limit, offset }); return { structuredContent: res } as any; } ); // csb_card const csbCardInput = { id: z.number().int().positive() } as const; server.registerTool( "csb_card", { title: "CSB: Get card", description: "Fetch Commander Spellbook card by numeric ID.", inputSchema: csbCardInput }, async ({ id }: { id: number }) => { const res = await CSB.getCard(id); return { structuredContent: res } as any; } ); // csb_build_card_index (force rebuild + write cache) const csbBuildIndexOutput = { path: z.string(), total: z.number().int().nonnegative(), size: z.number().int().nonnegative(), builtAtMs: z.number() } as const; server.registerTool( "csb_build_card_index", { title: "CSB: Build card index", description: "Rebuild oracleId→CSB id index and write cache.", outputSchema: csbBuildIndexOutput }, async () => { const data = await buildCsbIndex(); const path = await writeCsbIndex(data); return { structuredContent: { path, total: data.total, size: Object.keys(data.oracleToId).length, builtAtMs: data.builtAtMs } } as any; } ); // csb_read_card_index (no network) const csbReadIndexOutput = { path: z.string(), total: z.number().int().nonnegative(), size: z.number().int().nonnegative(), cachedAtMs: z.number() } as const; server.registerTool( "csb_read_card_index", { title: "CSB: Read card index", description: "Read oracleId→CSB id index from local cache.", outputSchema: csbReadIndexOutput }, async () => { const cachePath = process.env.CSB_CARD_INDEX_PATH || require('node:path').join(process.cwd(), 'cache', 'csb-card-index.json'); const disk = await readCsbIndex(cachePath); if (!disk) return { content: [{ type: "text", text: "No CSB card index cache found" }] } as any; const size = Object.keys(disk.data.oracleToId || {}).length; return { structuredContent: { path: cachePath, total: disk.data.total, size, cachedAtMs: disk.at } } as any; } ); // csb_lookup_by_oracle_ids const csbLookupOracleInput = { oracleIds: z.array(z.string().uuid()).min(1) } as const; const csbLookupOracleOutput = { found: z.record(z.string(), z.number().int()), missing: z.array(z.string()) } as const; server.registerTool( "csb_lookup_by_oracle_ids", { title: "CSB: Lookup by oracle IDs", description: "Map Scryfall oracle_id UUIDs to Commander Spellbook numeric IDs using the local index (builds if stale).", inputSchema: csbLookupOracleInput, outputSchema: csbLookupOracleOutput }, async ({ oracleIds }: { oracleIds: string[] }) => { const res = await lookupCsbIdsByOracle(oracleIds); return { structuredContent: res } as any; } ); // csb_find_combos_by_names const csbFindByNamesInput = { names: z.array(z.string()).min(1), fuzzy: z.boolean().optional().describe("Use Scryfall fuzzy name matching; default true"), limit: z.number().int().min(1).max(100).optional(), offset: z.number().int().min(0).optional() } as const; server.registerTool( "csb_find_combos_by_names", { title: "CSB: Find combos by card names", description: "Resolve names via Scryfall → oracle_id, map to CSB IDs via cached index, then call find-my-combos.", inputSchema: csbFindByNamesInput }, async ({ names, fuzzy = true, limit, offset }: { names: string[]; fuzzy?: boolean; limit?: number; offset?: number }) => { // Resolve names to oracle_ids via Scryfall const oracleIds: string[] = []; for (const name of names) { try { const card: any = await Scryfall.getCardNamed(name, fuzzy); const oid = (card as any)?.oracle_id || (card as any)?.oracleId || (card as any)?.oracleID; if (typeof oid === "string") oracleIds.push(oid); } catch { // ignore individual failures } } const uniqOids = Array.from(new Set(oracleIds)); if (uniqOids.length === 0) return { content: [{ type: "text", text: "No oracle IDs resolved from names" }] } as any; const mapRes = await lookupCsbIdsByOracle(uniqOids); const ids = Object.values(mapRes.found); if (ids.length === 0) { return { structuredContent: { mapping: mapRes, results: null } } as any; } const combos = await CSB.findMyCombos(ids, limit, offset); return { structuredContent: { mapping: mapRes, results: combos } } as any; } ); return server; }

Implementation Reference

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/latte-chan/scryfall-connector'

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