tarot-mcp-server
Version:
Model Context Protocol server for Rider-Waite tarot card readings
218 lines (217 loc) • 8.84 kB
JavaScript
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;
}
}