UNPKG

tarot-mcp-server

Version:

Model Context Protocol server for Rider-Waite tarot card readings

218 lines (217 loc) 8.84 kB
import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; import { getSecureRandom } from './utils.js'; // Helper to get __dirname in ES modules const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const CARD_DATA_PATH = path.join(__dirname, 'card-data.json'); /** * Manages tarot card data and operations. * Use the static `create()` method to instantiate. */ export class TarotCardManager { static instance; cards; allCards; /** * The constructor is private. Use the static async `create()` method to get an instance. * @param cards - The array of tarot cards loaded from the data source. */ constructor(cards) { this.allCards = Object.freeze(cards); this.cards = new Map(); this.initializeCards(); } /** * Asynchronously creates and initializes a TarotCardManager instance. * This is the correct way to instantiate the class. It follows the singleton pattern. */ static async create() { if (TarotCardManager.instance) { return TarotCardManager.instance; } try { const data = await fs.readFile(CARD_DATA_PATH, 'utf-8'); const { cards } = JSON.parse(data); if (!Array.isArray(cards)) { throw new Error('Card data is not in the expected format ({"cards": [...]})'); } TarotCardManager.instance = new TarotCardManager(cards); return TarotCardManager.instance; } catch (error) { console.error('Failed to load or parse tarot card data:', error); throw new Error('Could not initialize TarotCardManager. Card data is missing or corrupt.'); } } /** * Populates the internal map for quick card lookups. */ initializeCards() { this.allCards.forEach(card => { this.cards.set(card.id, card); // Also allow lookup by name (case-insensitive) this.cards.set(card.name.toLowerCase(), card); }); } /** * Get detailed information about a specific card. */ getCardInfo(cardName, orientation = "upright") { const card = this.findCard(cardName); if (!card) { return `Card "${cardName}" not found. Use the list_all_cards tool to see available cards.`; } const meanings = orientation === "upright" ? card.meanings.upright : card.meanings.reversed; const keywords = orientation === "upright" ? card.keywords.upright : card.keywords.reversed; let result = `# ${card.name} (${orientation.charAt(0).toUpperCase() + orientation.slice(1)})\n\n`; result += `**Arcana:** ${card.arcana === "major" ? "Major Arcana" : "Minor Arcana"}`; if (card.suit) { result += ` - ${card.suit.charAt(0).toUpperCase() + card.suit.slice(1)}`; } if (card.number !== undefined) { result += ` (${card.number})`; } result += "\n\n"; result += `**Keywords:** ${keywords.join(", ")}\n\n`; result += `**Description:** ${card.description}\n\n`; result += `## Meanings (${orientation.charAt(0).toUpperCase() + orientation.slice(1)})\n\n`; result += `**General:** ${meanings.general}\n\n`; result += `**Love & Relationships:** ${meanings.love}\n\n`; result += `**Career & Finance:** ${meanings.career}\n\n`; result += `**Health:** ${meanings.health}\n\n`; result += `**Spirituality:** ${meanings.spirituality}\n\n`; result += `## Symbolism\n\n`; result += card.symbolism.map(symbol => `• ${symbol}`).join("\n") + "\n\n"; if (card.element) { result += `**Element:** ${card.element.charAt(0).toUpperCase() + card.element.slice(1)}\n`; } if (card.astrology) { result += `**Astrology:** ${card.astrology}\n`; } if (card.numerology) { result += `**Numerology:** ${card.numerology}\n`; } return result; } /** * List all available cards, optionally filtered by category. */ listAllCards(category = "all") { let filteredCards = []; switch (category) { case "major_arcana": filteredCards = this.allCards.filter(card => card.arcana === "major"); break; case "minor_arcana": filteredCards = this.allCards.filter(card => card.arcana === "minor"); break; case "wands": filteredCards = this.allCards.filter(card => card.suit === "wands"); break; case "cups": filteredCards = this.allCards.filter(card => card.suit === "cups"); break; case "swords": filteredCards = this.allCards.filter(card => card.suit === "swords"); break; case "pentacles": filteredCards = this.allCards.filter(card => card.suit === "pentacles"); break; default: filteredCards = this.allCards; } let result = `# Tarot Cards`; if (category !== "all") { result += ` - ${category.replace("_", " ").replace(/\b\w/g, l => l.toUpperCase())}`; } result += `\n\n`; if (category === "all" || category === "major_arcana") { const majorCards = filteredCards.filter(card => card.arcana === "major"); if (majorCards.length > 0) { result += `## Major Arcana (${majorCards.length} cards)\n\n`; majorCards .sort((a, b) => (a.number ?? 0) - (b.number ?? 0)) .forEach(card => { result += `• **${card.name}** (${card.number}) - ${card.keywords.upright.slice(0, 3).join(", ")}\n`; }); result += "\n"; } } if (category === "all" || category === "minor_arcana" || ["wands", "cups", "swords", "pentacles"].includes(category)) { const suits = category === "all" || category === "minor_arcana" ? ["wands", "cups", "swords", "pentacles"] : [category]; suits.forEach(suit => { const suitCards = filteredCards.filter(card => card.suit === suit); if (suitCards.length > 0) { result += `## ${suit.charAt(0).toUpperCase() + suit.slice(1)} (${suitCards.length} cards)\n\n`; suitCards .sort((a, b) => (a.number ?? 0) - (b.number ?? 0)) .forEach(card => { result += `• **${card.name}** - ${card.keywords.upright.slice(0, 3).join(", ")}\n`; }); result += "\n"; } }); } result += `\n**Total cards:** ${filteredCards.length}\n`; result += `\nUse the \`get_card_info\` tool with any card name to get detailed information.`; return result; } /** * Find a card by name or ID (case-insensitive). */ findCard(identifier) { const normalizedIdentifier = identifier.toLowerCase().trim(); // Try exact ID or name match first let card = this.cards.get(normalizedIdentifier); if (card) return card; // Try partial name match as a fallback for (const c of this.allCards) { if (c.name.toLowerCase().includes(normalizedIdentifier)) { return c; } } return undefined; } /** * Fisher-Yates shuffle algorithm for true randomness. */ fisherYatesShuffle(array) { const shuffled = [...array]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(getSecureRandom() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } return shuffled; } /** * Get a random card from the deck. */ getRandomCard() { const randomIndex = Math.floor(getSecureRandom() * this.allCards.length); return this.allCards[randomIndex]; } /** * Get multiple random cards (without replacement). */ getRandomCards(count) { if (count > this.allCards.length) { throw new Error(`Cannot draw ${count} cards from a deck of ${this.allCards.length} cards`); } if (count === this.allCards.length) { return this.fisherYatesShuffle(this.allCards); } const shuffled = this.fisherYatesShuffle(this.allCards); return shuffled.slice(0, count); } /** * Get all cards in the deck. */ getAllCards() { return this.allCards; } }