Skip to main content
Glama

Scryfall MCP Server

by bmurdock
FEATURE_2_IMPLEMENTATION_PLAN.md73.3 kB
# Feature 2: Natural Language Query Builder Tool - Detailed Implementation Plan ## Executive Summary This document provides a comprehensive, granular implementation plan for **Feature 2: Natural Language Query Builder Tool** from the 3-feature-proposals.md document. This feature will convert natural language requests into optimized Scryfall search queries, significantly reducing the barrier to entry for AI assistants and users who are not familiar with Scryfall's search syntax. ## Feature Analysis and Scope ### Problem Statement - **Current Limitation**: AI assistants must manually construct Scryfall queries using complex syntax (e.g., `c:red t:creature pow>=2 cmc<=3 f:modern`) - **User Pain Point**: High barrier to entry for complex searches requiring knowledge of 150+ operators - **Missed Opportunities**: Manual query construction limits exploration and discovery capabilities ### Solution Overview Convert natural language descriptions like: - "red creatures under $5 for aggressive decks" → `c:r t:creature usd<=5 pow>=2 cmc<=3` - "blue counterspells in modern under $20" → `c:u o:counter f:modern usd<=20 t:instant` - "legendary artifacts that produce mana" → `t:legendary t:artifact o:mana (o:"add" OR o:"produces")` ### Success Criteria 1. **Accuracy**: 90%+ success rate for common natural language patterns 2. **Coverage**: Support 50+ natural language patterns covering major MTG concepts 3. **Optimization**: Generated queries return appropriate result counts (10-100 cards) 4. **Educational**: Provide explanations showing how natural language maps to Scryfall syntax 5. **Integration**: Seamless integration with existing MCP tool architecture ## Architecture Design ### High-Level Architecture ``` Natural Language Input ↓ ┌─────────────────────┐ │ NaturalLanguage │ ← Tokenize and extract concepts │ Parser │ └─────────────────────┘ ↓ ┌─────────────────────┐ │ ConceptExtractor │ ← Map concepts to Scryfall operators │ Engine │ └─────────────────────┘ ↓ ┌─────────────────────┐ │ QueryBuilder │ ← Assemble and optimize Scryfall query │ Engine │ └─────────────────────┘ ↓ ┌─────────────────────┐ │ QueryOptimizer │ ← Test and refine for optimal results │ & Validator │ └─────────────────────┘ ↓ Optimized Scryfall Query + Explanation ``` ### Core Components #### 1. **NaturalLanguageParser** - **Purpose**: Tokenize and extract semantic concepts from natural language - **Input**: Raw natural language string - **Output**: Structured `ParsedQuery` object with extracted concepts - **Location**: `src/natural-language/parser.ts` #### 2. **ConceptExtractor** - **Purpose**: Map natural language concepts to Scryfall operators and values - **Input**: Parsed concepts and context - **Output**: Operator mappings and constraints - **Location**: `src/natural-language/concept-extractor.ts` #### 3. **QueryBuilderEngine** - **Purpose**: Assemble Scryfall query from extracted concepts - **Input**: Concept mappings and optimization preferences - **Output**: Raw Scryfall query string - **Location**: `src/natural-language/query-builder.ts` #### 4. **QueryOptimizer** - **Purpose**: Test and refine queries for optimal result counts - **Input**: Raw query and target parameters - **Output**: Optimized query with performance metrics - **Location**: `src/natural-language/optimizer.ts` #### 5. **BuildScryfallQueryTool** - **Purpose**: MCP tool interface coordinating all components - **Input**: Natural language + preferences via MCP - **Output**: Formatted response with query, explanation, and alternatives - **Location**: `src/tools/build-scryfall-query.ts` ## Detailed Implementation Plan ### Phase 1: Core Natural Language Processing (Week 1) #### 1.1 Natural Language Parser Implementation **File**: `src/natural-language/parser.ts` **Key Data Structures**: ```typescript interface ParsedQuery { // Core card properties colors: ColorConcept[]; types: TypeConcept[]; subtypes: SubtypeConcept[]; // Gameplay mechanics keywords: KeywordConcept[]; abilities: AbilityConcept[]; mechanics: MechanicConcept[]; // Numeric constraints manaCost: ManaCostConcept[]; powerToughness: StatConcept[]; priceConstraints: PriceConcept[]; // Format and legality formats: FormatConcept[]; legality: LegalityConcept[]; // Strategic context archetypes: ArchetypeConcept[]; deckRoles: RoleConcept[]; strategies: StrategyConcept[]; // Set and collection sets: SetConcept[]; rarity: RarityConcept[]; timeConstraints: TimeConcept[]; // Text and flavor namePatterns: NameConcept[]; flavorText: FlavorConcept[]; artist: ArtistConcept[]; // Meta information confidence: number; ambiguities: Ambiguity[]; context: QueryContext; } interface ColorConcept { colors: string[]; // ['r', 'u'] for red/blue exact: boolean; // true for "exactly red and blue" inclusive: boolean; // true for "red or blue cards" exclusive: boolean; // true for "only red cards" multicolor: boolean; // true for "multicolor cards" colorless: boolean; // true for "colorless cards" confidence: number; } interface ArchetypeConcept { name: string; // "aggressive", "control", "combo" constraints: { cmcRange?: [number, number]; powerMin?: number; keywords?: string[]; functions?: string[]; cardTypes?: string[]; }; confidence: number; } interface PriceConcept { max?: number; min?: number; currency: 'usd' | 'eur' | 'tix'; condition?: 'budget' | 'value' | 'premium'; confidence: number; } ``` **Pattern Recognition Engines**: ```typescript class ColorPatternEngine { private readonly patterns = new Map([ // Basic colors ['red', { colors: ['r'], exact: false, confidence: 0.95 }], ['blue', { colors: ['u'], exact: false, confidence: 0.95 }], ['white', { colors: ['w'], exact: false, confidence: 0.95 }], ['black', { colors: ['b'], exact: false, confidence: 0.95 }], ['green', { colors: ['g'], exact: false, confidence: 0.95 }], // Guild names with higher confidence ['azorius', { colors: ['w', 'u'], exact: true, confidence: 0.98 }], ['dimir', { colors: ['u', 'b'], exact: true, confidence: 0.98 }], ['rakdos', { colors: ['b', 'r'], exact: true, confidence: 0.98 }], ['gruul', { colors: ['r', 'g'], exact: true, confidence: 0.98 }], ['selesnya', { colors: ['g', 'w'], exact: true, confidence: 0.98 }], ['orzhov', { colors: ['w', 'b'], exact: true, confidence: 0.98 }], ['izzet', { colors: ['u', 'r'], exact: true, confidence: 0.98 }], ['golgari', { colors: ['b', 'g'], exact: true, confidence: 0.98 }], ['boros', { colors: ['r', 'w'], exact: true, confidence: 0.98 }], ['simic', { colors: ['g', 'u'], exact: true, confidence: 0.98 }], // Shard names ['bant', { colors: ['g', 'w', 'u'], exact: true, confidence: 0.98 }], ['esper', { colors: ['w', 'u', 'b'], exact: true, confidence: 0.98 }], ['grixis', { colors: ['u', 'b', 'r'], exact: true, confidence: 0.98 }], ['jund', { colors: ['b', 'r', 'g'], exact: true, confidence: 0.98 }], ['naya', { colors: ['r', 'g', 'w'], exact: true, confidence: 0.98 }], // Wedge names ['abzan', { colors: ['w', 'b', 'g'], exact: true, confidence: 0.98 }], ['jeskai', { colors: ['u', 'r', 'w'], exact: true, confidence: 0.98 }], ['sultai', { colors: ['b', 'g', 'u'], exact: true, confidence: 0.98 }], ['mardu', { colors: ['r', 'w', 'b'], exact: true, confidence: 0.98 }], ['temur', { colors: ['g', 'u', 'r'], exact: true, confidence: 0.98 }], // Special color combinations ['multicolor', { colors: [], multicolor: true, confidence: 0.90 }], ['colorless', { colors: [], colorless: true, confidence: 0.95 }], ['rainbow', { colors: ['w', 'u', 'b', 'r', 'g'], exact: false, confidence: 0.85 }], ]); extract(text: string): ColorConcept[] { const concepts: ColorConcept[] = []; const lowerText = text.toLowerCase(); // Check for exact phrases first (higher confidence) for (const [pattern, concept] of this.patterns) { if (lowerText.includes(pattern)) { concepts.push({ colors: concept.colors || [], exact: concept.exact || false, inclusive: this.detectInclusive(text, pattern), exclusive: this.detectExclusive(text, pattern), multicolor: concept.multicolor || false, colorless: concept.colorless || false, confidence: concept.confidence }); } } return this.deduplicateAndMerge(concepts); } private detectInclusive(text: string, pattern: string): boolean { const inclusiveIndicators = ['or', 'any', 'either', 'include']; const context = this.getContextWindow(text, pattern, 5); return inclusiveIndicators.some(indicator => context.includes(indicator)); } private detectExclusive(text: string, pattern: string): boolean { const exclusiveIndicators = ['only', 'just', 'exactly', 'purely', 'solely']; const context = this.getContextWindow(text, pattern, 5); return exclusiveIndicators.some(indicator => context.includes(indicator)); } } ``` **Archetype Pattern Engine**: ```typescript class ArchetypePatternEngine { private readonly archetypeDefinitions = new Map([ ['aggressive', { constraints: { cmcRange: [1, 4], powerMin: 2, keywords: ['haste', 'trample', 'first strike', 'double strike'], functions: ['burn', 'direct damage'], cardTypes: ['creature', 'instant', 'sorcery'] }, confidence: 0.90 }], ['aggro', { constraints: { cmcRange: [1, 3], powerMin: 2, keywords: ['haste', 'prowess', 'menace'], functions: ['burn'], cardTypes: ['creature'] }, confidence: 0.92 }], ['control', { constraints: { cmcRange: [2, 8], keywords: ['flash', 'vigilance'], functions: ['counterspell', 'removal', 'draw', 'wipe'], cardTypes: ['instant', 'sorcery', 'enchantment', 'planeswalker'] }, confidence: 0.88 }], ['midrange', { constraints: { cmcRange: [3, 6], powerMin: 2, functions: ['removal', 'value'], cardTypes: ['creature', 'planeswalker'] }, confidence: 0.85 }], ['combo', { constraints: { keywords: ['flash', 'storm', 'cascade'], functions: ['tutor', 'draw', 'ritual'], cardTypes: ['instant', 'sorcery', 'artifact', 'enchantment'] }, confidence: 0.80 }], ['ramp', { constraints: { functions: ['ramp', 'mana acceleration'], cardTypes: ['land', 'artifact', 'creature', 'sorcery'], keywords: ['vigilance'] // for mana dorks }, confidence: 0.93 }], ['tribal', { constraints: { cardTypes: ['creature'], // Will be enhanced with specific creature types }, confidence: 0.85 }] ]); extract(text: string): ArchetypeConcept[] { const concepts: ArchetypeConcept[] = []; const lowerText = text.toLowerCase(); for (const [archetype, definition] of this.archetypeDefinitions) { if (this.matchesArchetype(lowerText, archetype)) { concepts.push({ name: archetype, constraints: definition.constraints, confidence: definition.confidence }); } } return concepts; } private matchesArchetype(text: string, archetype: string): boolean { // Direct mention if (text.includes(archetype)) return true; // Synonym matching const synonyms = this.getArchetypeSynonyms(archetype); return synonyms.some(synonym => text.includes(synonym)); } private getArchetypeSynonyms(archetype: string): string[] { const synonymMap = new Map([ ['aggressive', ['aggro', 'fast', 'beatdown', 'rush', 'tempo']], ['control', ['controlling', 'defensive', 'reactive', 'late game']], ['midrange', ['midgame', 'value', 'grindy', 'fair']], ['combo', ['synergy', 'engine', 'infinite', 'lock']], ['ramp', ['acceleration', 'big mana', 'ramping', 'fast mana']], ['tribal', ['creature type', 'synergy', 'lord effects']] ]); return synonymMap.get(archetype) || []; } } ``` **Price Pattern Engine**: ```typescript class PricePatternEngine { private readonly patterns = [ // Under patterns { regex: /under\s*\$?(\d+(?:\.\d{2})?)/i, type: 'max', confidence: 0.95 }, { regex: /less\s+than\s*\$?(\d+(?:\.\d{2})?)/i, type: 'max', confidence: 0.93 }, { regex: /below\s*\$?(\d+(?:\.\d{2})?)/i, type: 'max', confidence: 0.90 }, { regex: /\$?(\d+(?:\.\d{2})?)\s*or\s*less/i, type: 'max', confidence: 0.92 }, { regex: /\$?(\d+(?:\.\d{2})?)\s*and\s*under/i, type: 'max', confidence: 0.90 }, // Budget patterns { regex: /budget.*?\$?(\d+(?:\.\d{2})?)/i, type: 'max', confidence: 0.85 }, { regex: /cheap.*?\$?(\d+(?:\.\d{2})?)/i, type: 'max', confidence: 0.80 }, { regex: /affordable.*?\$?(\d+(?:\.\d{2})?)/i, type: 'max', confidence: 0.82 }, // Over patterns { regex: /over\s*\$?(\d+(?:\.\d{2})?)/i, type: 'min', confidence: 0.95 }, { regex: /more\s+than\s*\$?(\d+(?:\.\d{2})?)/i, type: 'min', confidence: 0.93 }, { regex: /above\s*\$?(\d+(?:\.\d{2})?)/i, type: 'min', confidence: 0.90 }, // Range patterns { regex: /between\s*\$?(\d+(?:\.\d{2})?)\s*(?:and|to|-)\s*\$?(\d+(?:\.\d{2})?)/i, type: 'range', confidence: 0.88 }, { regex: /\$?(\d+(?:\.\d{2})?)\s*(?:-|to)\s*\$?(\d+(?:\.\d{2})?)/i, type: 'range', confidence: 0.85 }, // Exact patterns { regex: /exactly\s*\$?(\d+(?:\.\d{2})?)/i, type: 'exact', confidence: 0.90 }, { regex: /costs\s*\$?(\d+(?:\.\d{2})?)/i, type: 'exact', confidence: 0.85 }, ]; extract(text: string): PriceConcept[] { const concepts: PriceConcept[] = []; for (const pattern of this.patterns) { const match = text.match(pattern.regex); if (match) { const concept: PriceConcept = { currency: 'usd', // Default to USD confidence: pattern.confidence }; if (pattern.type === 'max') { concept.max = parseFloat(match[1]); } else if (pattern.type === 'min') { concept.min = parseFloat(match[1]); } else if (pattern.type === 'range') { concept.min = parseFloat(match[1]); concept.max = parseFloat(match[2]); } else if (pattern.type === 'exact') { concept.min = concept.max = parseFloat(match[1]); } // Detect currency if (text.includes('euro') || text.includes('eur')) { concept.currency = 'eur'; } else if (text.includes('tix') || text.includes('ticket')) { concept.currency = 'tix'; } // Detect condition context concept.condition = this.detectCondition(text, match.index || 0); concepts.push(concept); } } return this.deduplicateAndMerge(concepts); } private detectCondition(text: string, position: number): 'budget' | 'value' | 'premium' | undefined { const context = text.substring(Math.max(0, position - 20), position + 20).toLowerCase(); if (context.includes('budget') || context.includes('cheap') || context.includes('affordable')) { return 'budget'; } else if (context.includes('value') || context.includes('efficient')) { return 'value'; } else if (context.includes('premium') || context.includes('expensive') || context.includes('high end')) { return 'premium'; } return undefined; } } ``` #### 1.2 Type and Keyword Extraction **File**: `src/natural-language/extractors/type-extractor.ts` ```typescript class TypePatternEngine { private readonly cardTypes = new Map([ // Primary types ['creature', { type: 'creature', confidence: 0.98 }], ['instant', { type: 'instant', confidence: 0.98 }], ['sorcery', { type: 'sorcery', confidence: 0.98 }], ['artifact', { type: 'artifact', confidence: 0.98 }], ['enchantment', { type: 'enchantment', confidence: 0.98 }], ['planeswalker', { type: 'planeswalker', confidence: 0.98 }], ['land', { type: 'land', confidence: 0.98 }], ['battle', { type: 'battle', confidence: 0.98 }], // Synonyms and common terms ['spell', { type: 'instant OR sorcery', confidence: 0.85 }], ['permanent', { type: 'creature OR artifact OR enchantment OR planeswalker OR land', confidence: 0.80 }], ['nonland', { type: '-t:land', confidence: 0.85 }], ['noncreature', { type: '-t:creature', confidence: 0.85 }], // Supertypes ['legendary', { type: 'legendary', confidence: 0.95 }], ['basic', { type: 'basic', confidence: 0.95 }], ['snow', { type: 'snow', confidence: 0.95 }], // Common subtypes ['human', { subtype: 'human', confidence: 0.90 }], ['elf', { subtype: 'elf', confidence: 0.95 }], ['goblin', { subtype: 'goblin', confidence: 0.95 }], ['zombie', { subtype: 'zombie', confidence: 0.95 }], ['dragon', { subtype: 'dragon', confidence: 0.95 }], ['angel', { subtype: 'angel', confidence: 0.95 }], ['demon', { subtype: 'demon', confidence: 0.95 }], ['wizard', { subtype: 'wizard', confidence: 0.90 }], ['warrior', { subtype: 'warrior', confidence: 0.90 }], ['knight', { subtype: 'knight', confidence: 0.90 }], ['beast', { subtype: 'beast', confidence: 0.90 }], ['spirit', { subtype: 'spirit', confidence: 0.90 }], ['elemental', { subtype: 'elemental', confidence: 0.90 }], // Equipment and vehicles ['equipment', { subtype: 'equipment', confidence: 0.95 }], ['vehicle', { subtype: 'vehicle', confidence: 0.95 }], // Artifact subtypes ['treasure', { subtype: 'treasure', confidence: 0.95 }], ['food', { subtype: 'food', confidence: 0.95 }], ['clue', { subtype: 'clue', confidence: 0.95 }], // Land subtypes ['mountain', { subtype: 'mountain', confidence: 0.90 }], ['island', { subtype: 'island', confidence: 0.90 }], ['forest', { subtype: 'forest', confidence: 0.90 }], ['plains', { subtype: 'plains', confidence: 0.90 }], ['swamp', { subtype: 'swamp', confidence: 0.90 }], ]); extract(text: string): TypeConcept[] { const concepts: TypeConcept[] = []; const lowerText = text.toLowerCase(); for (const [pattern, definition] of this.cardTypes) { if (lowerText.includes(pattern)) { concepts.push({ type: definition.type, subtype: definition.subtype, confidence: definition.confidence, context: this.getContextWindow(text, pattern, 3) }); } } return this.deduplicateAndMerge(concepts); } } ``` #### 1.3 Integration and Testing **File**: `src/natural-language/parser.ts` (Main Parser) ```typescript export class NaturalLanguageParser { private readonly colorEngine = new ColorPatternEngine(); private readonly typeEngine = new TypePatternEngine(); private readonly archetypeEngine = new ArchetypePatternEngine(); private readonly priceEngine = new PricePatternEngine(); private readonly keywordEngine = new KeywordPatternEngine(); private readonly formatEngine = new FormatPatternEngine(); private readonly mechanicEngine = new MechanicPatternEngine(); parse(naturalQuery: string, context?: QueryContext): ParsedQuery { // Preprocess text const cleanedText = this.preprocessText(naturalQuery); // Extract all concepts in parallel const [ colors, types, archetypes, prices, keywords, formats, mechanics ] = await Promise.all([ this.colorEngine.extract(cleanedText), this.typeEngine.extract(cleanedText), this.archetypeEngine.extract(cleanedText), this.priceEngine.extract(cleanedText), this.keywordEngine.extract(cleanedText), this.formatEngine.extract(cleanedText), this.mechanicEngine.extract(cleanedText) ]); // Resolve conflicts and calculate confidence const resolved = this.resolveConflicts({ colors, types, archetypes, prices, keywords, formats, mechanics }); return { ...resolved, confidence: this.calculateOverallConfidence(resolved), ambiguities: this.detectAmbiguities(resolved), context: context || {} }; } private preprocessText(text: string): string { return text .toLowerCase() .replace(/[^\w\s$.-]/g, ' ') // Remove special chars except $, ., - .replace(/\s+/g, ' ') // Normalize whitespace .trim(); } private resolveConflicts(concepts: any): ParsedQuery { // Implement conflict resolution logic // e.g., if both "red" and "blue" are mentioned with "only", resolve to multicolor // Handle overlapping archetypes, contradictory price constraints, etc. return { colors: this.resolveColorConflicts(concepts.colors), types: this.resolveTypeConflicts(concepts.types), // ... other resolutions }; } private calculateOverallConfidence(parsed: ParsedQuery): number { const conceptConfidences = [ ...parsed.colors.map(c => c.confidence), ...parsed.types.map(t => t.confidence), ...parsed.archetypes.map(a => a.confidence), // ... other concept confidences ]; if (conceptConfidences.length === 0) return 0; // Weighted average with bias toward higher confidence concepts return conceptConfidences.reduce((sum, conf, idx) => { const weight = Math.pow(conf, 1.5); // Higher confidence gets more weight return sum + (conf * weight); }, 0) / conceptConfidences.length; } } ``` ### Phase 2: Query Building Engine (Week 2) #### 2.1 Concept to Scryfall Mapping **File**: `src/natural-language/concept-extractor.ts` ```typescript interface ConceptMapping { operator: string; value: string; comparison?: '=' | '>' | '<' | '>=' | '<=' | '!='; negation?: boolean; confidence: number; priority: number; // For conflict resolution } export class ConceptExtractor { mapColors(colors: ColorConcept[]): ConceptMapping[] { const mappings: ConceptMapping[] = []; for (const colorConcept of colors) { if (colorConcept.colorless) { mappings.push({ operator: 'c', value: 'c', confidence: colorConcept.confidence, priority: 10 }); } else if (colorConcept.multicolor) { mappings.push({ operator: 'c', value: 'm', confidence: colorConcept.confidence, priority: 9 }); } else if (colorConcept.colors.length > 0) { const value = colorConcept.exact ? colorConcept.colors.join('') : colorConcept.colors.join(''); const operator = colorConcept.exact ? 'c' : 'c'; const comparison = colorConcept.inclusive ? '>=' : '='; mappings.push({ operator, value, comparison: colorConcept.exact ? '=' : comparison, confidence: colorConcept.confidence, priority: 10 }); } } return mappings; } mapArchetypes(archetypes: ArchetypeConcept[]): ConceptMapping[] { const mappings: ConceptMapping[] = []; for (const archetype of archetypes) { const constraints = archetype.constraints; // Map CMC constraints if (constraints.cmcRange) { const [min, max] = constraints.cmcRange; mappings.push({ operator: 'cmc', value: min.toString(), comparison: '>=', confidence: archetype.confidence * 0.8, priority: 6 }); mappings.push({ operator: 'cmc', value: max.toString(), comparison: '<=', confidence: archetype.confidence * 0.8, priority: 6 }); } // Map power constraints if (constraints.powerMin) { mappings.push({ operator: 'pow', value: constraints.powerMin.toString(), comparison: '>=', confidence: archetype.confidence * 0.7, priority: 5 }); } // Map keyword constraints if (constraints.keywords) { for (const keyword of constraints.keywords) { mappings.push({ operator: 'o', value: keyword, confidence: archetype.confidence * 0.6, priority: 4 }); } } // Map function constraints if (constraints.functions) { for (const func of constraints.functions) { mappings.push({ operator: 'function', value: func, confidence: archetype.confidence * 0.8, priority: 7 }); } } // Map card type constraints if (constraints.cardTypes) { for (const type of constraints.cardTypes) { mappings.push({ operator: 't', value: type, confidence: archetype.confidence * 0.9, priority: 8 }); } } } return mappings; } mapPrices(prices: PriceConcept[]): ConceptMapping[] { const mappings: ConceptMapping[] = []; for (const price of prices) { const currencyOperator = price.currency === 'eur' ? 'eur' : price.currency === 'tix' ? 'tix' : 'usd'; if (price.max !== undefined) { mappings.push({ operator: currencyOperator, value: price.max.toString(), comparison: '<=', confidence: price.confidence, priority: 8 }); } if (price.min !== undefined) { mappings.push({ operator: currencyOperator, value: price.min.toString(), comparison: '>=', confidence: price.confidence, priority: 8 }); } } return mappings; } resolveConflicts(mappings: ConceptMapping[]): ConceptMapping[] { // Group by operator const grouped = new Map<string, ConceptMapping[]>(); for (const mapping of mappings) { const key = mapping.operator; if (!grouped.has(key)) { grouped.set(key, []); } grouped.get(key)!.push(mapping); } const resolved: ConceptMapping[] = []; for (const [operator, operatorMappings] of grouped) { if (operatorMappings.length === 1) { resolved.push(operatorMappings[0]); continue; } // Resolve conflicts based on priority and confidence const sorted = operatorMappings.sort((a, b) => { const priorityDiff = b.priority - a.priority; if (priorityDiff !== 0) return priorityDiff; return b.confidence - a.confidence; }); // Special conflict resolution rules if (operator === 'c') { resolved.push(...this.resolveColorConflicts(sorted)); } else if (operator === 'cmc') { resolved.push(...this.resolveCmcConflicts(sorted)); } else if (operator === 'usd' || operator === 'eur' || operator === 'tix') { resolved.push(...this.resolvePriceConflicts(sorted)); } else { // Default: take highest priority/confidence resolved.push(sorted[0]); } } return resolved; } private resolveColorConflicts(mappings: ConceptMapping[]): ConceptMapping[] { // Complex color conflict resolution // Handle cases like "red and blue" vs "only red" vs "multicolor" const hasExact = mappings.some(m => m.comparison === '='); const hasInclusive = mappings.some(m => m.comparison === '>='); if (hasExact && hasInclusive) { // Exact takes precedence return mappings.filter(m => m.comparison === '='); } // Merge compatible color requirements return this.mergeColorMappings(mappings); } } ``` #### 2.2 Query Builder Engine **File**: `src/natural-language/query-builder.ts` ```typescript interface BuildOptions { optimize_for: 'precision' | 'recall' | 'discovery' | 'budget'; format?: string; max_results?: number; price_budget?: PriceBudget; } interface BuildResult { query: string; explanation: string; confidence: number; alternatives: AlternativeQuery[]; optimizations: QueryOptimization[]; } export class QueryBuilderEngine { constructor( private conceptExtractor: ConceptExtractor, private scryfallClient: ScryfallClient ) {} async build(parsed: ParsedQuery, options: BuildOptions): Promise<BuildResult> { // Extract concept mappings const mappings = this.conceptExtractor.extractMappings(parsed); // Build base query const baseQuery = this.buildBaseQuery(mappings); // Apply format constraints const formatQuery = this.applyFormat(baseQuery, options.format); // Apply optimization strategy const optimizedQuery = this.applyOptimization(formatQuery, options.optimize_for); // Test query and adjust if needed const testedQuery = await this.testAndAdjust(optimizedQuery, options); // Generate explanation const explanation = this.generateExplanation(mappings, options); // Generate alternatives const alternatives = this.generateAlternatives(parsed, mappings, options); return { query: testedQuery.query, explanation, confidence: this.calculateBuildConfidence(parsed, mappings, testedQuery), alternatives, optimizations: testedQuery.optimizations }; } private buildBaseQuery(mappings: ConceptMapping[]): string { const queryParts: string[] = []; // Group mappings by operator for complex queries const grouped = this.groupMappingsByOperator(mappings); for (const [operator, operatorMappings] of grouped) { const part = this.buildOperatorQuery(operator, operatorMappings); if (part) { queryParts.push(part); } } return queryParts.join(' '); } private buildOperatorQuery(operator: string, mappings: ConceptMapping[]): string { if (mappings.length === 0) return ''; if (mappings.length === 1) { const mapping = mappings[0]; const prefix = mapping.negation ? '-' : ''; const comparison = mapping.comparison || ''; return `${prefix}${operator}:${comparison}${mapping.value}`; } // Handle multiple values for same operator if (operator === 'o') { // Oracle text searches can be combined with OR const values = mappings.map(m => m.value); return `(${values.map(v => `o:"${v}"`).join(' OR ')})`; } else if (operator === 't') { // Type searches can be combined with OR const values = mappings.map(m => m.value); return `(${values.map(v => `t:${v}`).join(' OR ')})`; } else if (['cmc', 'pow', 'tou', 'usd', 'eur', 'tix'].includes(operator)) { // Numeric operators need range handling return this.buildNumericRange(operator, mappings); } // Default: use highest confidence mapping const best = mappings.reduce((a, b) => a.confidence > b.confidence ? a : b ); const prefix = best.negation ? '-' : ''; const comparison = best.comparison || ''; return `${prefix}${operator}:${comparison}${best.value}`; } private buildNumericRange(operator: string, mappings: ConceptMapping[]): string { const minMappings = mappings.filter(m => m.comparison === '>=' || m.comparison === '>'); const maxMappings = mappings.filter(m => m.comparison === '<=' || m.comparison === '<'); const exactMappings = mappings.filter(m => !m.comparison || m.comparison === '='); if (exactMappings.length > 0) { // Exact value takes precedence const best = exactMappings.reduce((a, b) => a.confidence > b.confidence ? a : b ); return `${operator}:${best.value}`; } const parts: string[] = []; if (minMappings.length > 0) { const best = minMappings.reduce((a, b) => a.confidence > b.confidence ? a : b ); parts.push(`${operator}:${best.comparison}${best.value}`); } if (maxMappings.length > 0) { const best = maxMappings.reduce((a, b) => a.confidence > b.confidence ? a : b ); parts.push(`${operator}:${best.comparison}${best.value}`); } return parts.join(' '); } private applyOptimization(query: string, strategy: string): string { switch (strategy) { case 'precision': // Add constraints to reduce false positives return this.optimizeForPrecision(query); case 'recall': // Broaden query to catch more results return this.optimizeForRecall(query); case 'discovery': // Add interesting constraints for exploration return this.optimizeForDiscovery(query); case 'budget': // Add budget-friendly constraints return this.optimizeForBudget(query); default: return query; } } private optimizeForPrecision(query: string): string { // Add constraints that improve precision let optimized = query; // If no format specified, default to popular formats for precision if (!query.includes('f:')) { optimized += ' (f:standard OR f:modern OR f:commander)'; } // Exclude very expensive cards unless price is explicitly mentioned if (!query.match(/(?:usd|eur|tix):/)) { optimized += ' usd<=50'; } return optimized; } private optimizeForRecall(query: string): string { // Remove overly restrictive constraints let optimized = query; // Relax power/toughness constraints optimized = optimized.replace(/pow:>=(\d+)/, (match, value) => { const newValue = Math.max(1, parseInt(value) - 1); return `pow:>=${newValue}`; }); // Broaden type searches optimized = optimized.replace(/t:(\w+)(?!\w)/, (match, type) => { const relatedTypes = this.getRelatedTypes(type); if (relatedTypes.length > 0) { return `(t:${type} OR t:${relatedTypes.join(' OR t:')})`; } return match; }); return optimized; } private async testAndAdjust(query: string, options: BuildOptions): Promise<{ query: string; optimizations: QueryOptimization[]; }> { try { // Test the query with a small limit const testResult = await this.scryfallClient.searchCards({ query, limit: 1 }); const optimizations: QueryOptimization[] = []; let adjustedQuery = query; if (testResult.total_cards === 0) { // No results - broaden the query adjustedQuery = this.broadenQuery(query); optimizations.push({ type: 'broadening', reason: 'Original query returned no results', change: `${query} → ${adjustedQuery}` }); } else if (testResult.total_cards > (options.max_results || 20) * 10) { // Too many results - narrow the query adjustedQuery = this.narrowQuery(query); optimizations.push({ type: 'narrowing', reason: `Original query returned ${testResult.total_cards} results`, change: `${query} → ${adjustedQuery}` }); } return { query: adjustedQuery, optimizations }; } catch (error) { // If query fails, return original return { query, optimizations: [] }; } } private generateExplanation(mappings: ConceptMapping[], options: BuildOptions): string { const explanations: string[] = []; // Group explanations by concept type const colorMappings = mappings.filter(m => m.operator === 'c' || m.operator === 'id'); const typeMappings = mappings.filter(m => m.operator === 't'); const functionMappings = mappings.filter(m => m.operator === 'function' || m.operator === 'o'); const priceMappings = mappings.filter(m => ['usd', 'eur', 'tix'].includes(m.operator)); const statMappings = mappings.filter(m => ['pow', 'tou', 'cmc'].includes(m.operator)); if (colorMappings.length > 0) { explanations.push(this.explainColorConstraints(colorMappings)); } if (typeMappings.length > 0) { explanations.push(this.explainTypeConstraints(typeMappings)); } if (functionMappings.length > 0) { explanations.push(this.explainFunctionConstraints(functionMappings)); } if (priceMappings.length > 0) { explanations.push(this.explainPriceConstraints(priceMappings)); } if (statMappings.length > 0) { explanations.push(this.explainStatConstraints(statMappings)); } if (options.format) { explanations.push(`legal in ${options.format} format`); } return explanations.join(', '); } private generateAlternatives( parsed: ParsedQuery, mappings: ConceptMapping[], options: BuildOptions ): AlternativeQuery[] { const alternatives: AlternativeQuery[] = []; // Generate format alternatives if (!options.format) { const popularFormats = ['standard', 'modern', 'commander', 'legacy']; for (const format of popularFormats) { const altQuery = this.buildBaseQuery(mappings) + ` f:${format}`; alternatives.push({ query: altQuery, description: `Same search restricted to ${format} format`, type: 'format_restriction', confidence: 0.8 }); } } // Generate strategy alternatives const strategies = ['precision', 'recall', 'discovery', 'budget']; for (const strategy of strategies) { if (strategy !== options.optimize_for) { const altQuery = this.applyOptimization(this.buildBaseQuery(mappings), strategy); alternatives.push({ query: altQuery, description: `Optimized for ${strategy}`, type: 'optimization', confidence: 0.7 }); } } // Generate concept variations if (parsed.colors.length > 0) { alternatives.push(...this.generateColorAlternatives(parsed.colors, mappings)); } return alternatives.slice(0, 3); // Limit to top 3 alternatives } } ``` ### Phase 3: MCP Tool Implementation (Week 3) #### 3.1 Tool Definition and Validation **File**: `src/tools/build-scryfall-query.ts` ```typescript import { z } from 'zod'; import { ScryfallClient } from '../services/scryfall-client.js'; import { NaturalLanguageParser } from '../natural-language/parser.js'; import { QueryBuilderEngine } from '../natural-language/query-builder.js'; import { ConceptExtractor } from '../natural-language/concept-extractor.js'; import { validateBuildQueryParams } from '../utils/validators.js'; import { ValidationError, ScryfallAPIError } from '../types/mcp-types.js'; // Zod schema for input validation export const BuildQueryParamsSchema = z.object({ natural_query: z.string() .min(1, 'Natural query cannot be empty') .max(500, 'Natural query too long (max 500 characters)'), format: z.enum([ 'standard', 'modern', 'legacy', 'vintage', 'commander', 'pioneer', 'brawl', 'pauper', 'penny', 'historic', 'alchemy' ]).optional(), optimize_for: z.enum(['precision', 'recall', 'discovery', 'budget']) .default('precision'), max_results: z.number() .min(1, 'Max results must be at least 1') .max(175, 'Max results cannot exceed 175') .default(20), price_budget: z.object({ max: z.number().min(0, 'Price budget cannot be negative'), currency: z.enum(['usd', 'eur', 'tix']).default('usd') }).optional(), include_alternatives: z.boolean().default(true), explain_mapping: z.boolean().default(true), test_query: z.boolean().default(true) }); export type BuildQueryParams = z.infer<typeof BuildQueryParamsSchema>; export class BuildScryfallQueryTool { readonly name = 'build_scryfall_query'; readonly description = 'Convert natural language requests into optimized Scryfall search queries with explanations and alternatives'; readonly inputSchema = { type: 'object' as const, properties: { natural_query: { type: 'string', description: 'Natural language description of what you want to find (e.g., "red creatures under $5 for aggressive decks", "blue counterspells in modern")', minLength: 1, maxLength: 500 }, format: { type: 'string', enum: ['standard', 'modern', 'legacy', 'vintage', 'commander', 'pioneer', 'brawl', 'pauper', 'penny', 'historic', 'alchemy'], description: 'Magic format to restrict search to (optional)' }, optimize_for: { type: 'string', enum: ['precision', 'recall', 'discovery', 'budget'], default: 'precision', description: 'Search optimization strategy: precision (fewer, more relevant results), recall (broader search), discovery (interesting cards), budget (cost-effective)' }, max_results: { type: 'number', minimum: 1, maximum: 175, default: 20, description: 'Target number of results for optimization' }, price_budget: { type: 'object', properties: { max: { type: 'number', minimum: 0, description: 'Maximum price per card' }, currency: { type: 'string', enum: ['usd', 'eur', 'tix'], default: 'usd', description: 'Currency for price constraints' } }, description: 'Price constraints for the search' }, include_alternatives: { type: 'boolean', default: true, description: 'Whether to include alternative query suggestions' }, explain_mapping: { type: 'boolean', default: true, description: 'Whether to include detailed explanation of natural language to Scryfall mapping' }, test_query: { type: 'boolean', default: true, description: 'Whether to test the generated query and optimize based on results' } }, required: ['natural_query'] }; constructor( private readonly scryfallClient: ScryfallClient, private readonly parser = new NaturalLanguageParser(), private readonly conceptExtractor = new ConceptExtractor(), private readonly queryBuilder = new QueryBuilderEngine(new ConceptExtractor(), scryfallClient) ) {} async execute(args: unknown) { try { // Validate input parameters const params = validateBuildQueryParams(args); // Parse natural language const parsed = this.parser.parse(params.natural_query, { targetFormat: params.format, optimizationStrategy: params.optimize_for, maxResults: params.max_results }); // Check parsing confidence if (parsed.confidence < 0.3) { return this.handleLowConfidenceParsing(parsed, params); } // Build Scryfall query const buildOptions = { optimize_for: params.optimize_for, format: params.format, max_results: params.max_results, price_budget: params.price_budget }; const buildResult = await this.queryBuilder.build(parsed, buildOptions); // Test query if requested let testResult; if (params.test_query) { testResult = await this.testQuery(buildResult.query); } // Format response const responseText = this.formatResponse( buildResult, testResult, params, parsed ); return { content: [{ type: 'text', text: responseText }] }; } catch (error) { return this.handleError(error); } } private async testQuery(query: string) { try { return await this.scryfallClient.searchCards({ query, limit: 5 // Small test to check viability }); } catch (error) { return null; // Query failed, but we'll still return the generated query } } private formatResponse( buildResult: BuildResult, testResult: any, params: BuildQueryParams, parsed: ParsedQuery ): string { let response = `**Generated Scryfall Query:**\n\`${buildResult.query}\`\n\n`; // Add test results if (testResult) { response += `**Query Test Results:**\n`; response += `✅ Query is valid and returns ${testResult.total_cards} cards\n`; if (testResult.total_cards === 0) { response += `⚠️ No results found. Consider broadening your search.\n`; } else if (testResult.total_cards > params.max_results * 5) { response += `⚠️ Many results (${testResult.total_cards}). Consider adding more constraints.\n`; } else { response += `✨ Good result count for exploration.\n`; } response += '\n'; } // Add explanation if requested if (params.explain_mapping) { response += `**Explanation:**\n${buildResult.explanation}\n\n`; response += `**Natural Language Mapping:**\n`; response += this.formatConceptMapping(parsed); response += '\n'; } // Add confidence information response += `**Confidence Scores:**\n`; response += `• Parsing Confidence: ${(parsed.confidence * 100).toFixed(0)}%\n`; response += `• Query Build Confidence: ${(buildResult.confidence * 100).toFixed(0)}%\n`; if (parsed.ambiguities && parsed.ambiguities.length > 0) { response += `• Detected Ambiguities: ${parsed.ambiguities.length}\n`; } response += '\n'; // Add optimizations applied if (buildResult.optimizations.length > 0) { response += `**Optimizations Applied:**\n`; for (const opt of buildResult.optimizations) { response += `• ${opt.type}: ${opt.reason}\n`; } response += '\n'; } // Add alternatives if requested if (params.include_alternatives && buildResult.alternatives.length > 0) { response += `**Alternative Queries:**\n`; for (const alt of buildResult.alternatives) { response += `• \`${alt.query}\` - ${alt.description}\n`; } response += '\n'; } // Add usage instructions response += `**Usage:**\n`; response += `You can now use this query with the \`search_cards\` tool:\n`; response += `\`\`\`json\n`; response += `{\n`; response += ` "tool": "search_cards",\n`; response += ` "arguments": {\n`; response += ` "query": "${buildResult.query}",\n`; response += ` "limit": ${params.max_results}\n`; response += ` }\n`; response += `}\n`; response += `\`\`\`\n`; return response; } private formatConceptMapping(parsed: ParsedQuery): string { const mappings: string[] = []; if (parsed.colors.length > 0) { const colorDescs = parsed.colors.map(c => { const colorNames = c.colors.map(color => this.getColorName(color)).join(', '); const type = c.exact ? 'exactly' : c.inclusive ? 'including' : 'any'; return `${type} ${colorNames}`; }); mappings.push(`• Colors: ${colorDescs.join('; ')}`); } if (parsed.types.length > 0) { const types = parsed.types.map(t => t.type || t.subtype).join(', '); mappings.push(`• Types: ${types}`); } if (parsed.archetypes.length > 0) { const archetypes = parsed.archetypes.map(a => a.name).join(', '); mappings.push(`• Archetypes: ${archetypes}`); } if (parsed.priceConstraints.length > 0) { const prices = parsed.priceConstraints.map(p => { if (p.max !== undefined && p.min !== undefined) { return `${p.min}-${p.max} ${p.currency.toUpperCase()}`; } else if (p.max !== undefined) { return `under ${p.max} ${p.currency.toUpperCase()}`; } else if (p.min !== undefined) { return `over ${p.min} ${p.currency.toUpperCase()}`; } return ''; }).filter(p => p); mappings.push(`• Price: ${prices.join(', ')}`); } if (parsed.formats.length > 0) { const formats = parsed.formats.map(f => f.name).join(', '); mappings.push(`• Formats: ${formats}`); } return mappings.join('\n'); } private handleLowConfidenceParsing(parsed: ParsedQuery, params: BuildQueryParams) { let response = `**⚠️ Low Confidence Parsing**\n\n`; response += `I had difficulty understanding your natural language query "${params.natural_query}".\n\n`; response += `**What I understood:**\n`; response += this.formatConceptMapping(parsed); response += '\n\n'; if (parsed.ambiguities.length > 0) { response += `**Ambiguities detected:**\n`; for (const ambiguity of parsed.ambiguities) { response += `• ${ambiguity.description}\n`; } response += '\n'; } response += `**Suggestions:**\n`; response += `• Try using more specific terms (e.g., "red creatures" instead of "red cards")\n`; response += `• Include format information (e.g., "in modern" or "for commander")\n`; response += `• Be more explicit about constraints (e.g., "under $10" or "with power 3 or greater")\n`; response += `• Use Magic terminology (e.g., "instant", "sorcery", "planeswalker")\n\n`; response += `**Examples of well-understood queries:**\n`; response += `• "red aggressive creatures under $5 for modern"\n`; response += `• "blue counterspells in standard format"\n`; response += `• "legendary artifacts that produce mana for commander"\n`; response += `• "white removal spells with converted mana cost 2 or less"\n`; return { content: [{ type: 'text', text: response }], isError: false // Not an error, just low confidence }; } private handleError(error: unknown) { if (error instanceof ValidationError) { return { content: [{ type: 'text', text: `❌ **Parameter Validation Error**\n\n${error.message}\n\nPlease check your input and try again.` }], isError: true }; } if (error instanceof ScryfallAPIError) { const statusMessages = { 404: 'Query syntax error - the generated query was not accepted by Scryfall', 422: 'Invalid query parameters', 429: 'Rate limit exceeded - please try again in a moment' }; const message = statusMessages[error.status as keyof typeof statusMessages] || `Scryfall API error: ${error.message}`; return { content: [{ type: 'text', text: `❌ **Scryfall API Error**\n\n${message}\n\nThe generated query may need manual adjustment.` }], isError: true }; } // Generic error const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; return { content: [{ type: 'text', text: `❌ **Error Building Query**\n\n${errorMessage}\n\nPlease try rephrasing your natural language query.` }], isError: true }; } private getColorName(colorCode: string): string { const colorMap = { 'w': 'white', 'u': 'blue', 'b': 'black', 'r': 'red', 'g': 'green', 'c': 'colorless', 'm': 'multicolor' }; return colorMap[colorCode as keyof typeof colorMap] || colorCode; } } ``` #### 3.2 Validation Functions **File**: `src/utils/validators.ts` (additions) ```typescript import { BuildQueryParamsSchema, type BuildQueryParams } from '../tools/build-scryfall-query.js'; import { ValidationError } from '../types/mcp-types.js'; export function validateBuildQueryParams(params: unknown): BuildQueryParams { try { return BuildQueryParamsSchema.parse(params); } catch (error) { if (error instanceof z.ZodError) { const firstError = error.errors[0]; const fieldPath = firstError.path.join('.'); // Provide helpful error messages for common issues let message = `Invalid parameter '${fieldPath}': ${firstError.message}`; if (fieldPath === 'natural_query') { message += '\n\nTip: Describe what you want to find in plain English, like:\n'; message += '• "red creatures under $5 for aggressive decks"\n'; message += '• "blue counterspells in modern format"\n'; message += '• "legendary artifacts that produce mana"'; } else if (fieldPath === 'format') { message += '\n\nSupported formats: standard, modern, legacy, vintage, commander, pioneer, brawl, pauper, penny, historic, alchemy'; } else if (fieldPath === 'optimize_for') { message += '\n\nOptimization strategies:\n'; message += '• precision: Fewer, more relevant results\n'; message += '• recall: Broader search with more results\n'; message += '• discovery: Focus on interesting/unique cards\n'; message += '• budget: Cost-effective options'; } throw new ValidationError(message, fieldPath); } throw error; } } ``` ### Phase 4: Integration and Testing (Week 4) #### 4.1 Server Integration **File**: `src/server.ts` (modifications) ```typescript import { BuildScryfallQueryTool } from './tools/build-scryfall-query.js'; export class ScryfallMCPServer { // ... existing code ... constructor(options: ScryfallMCPServerOptions = {}) { // ... existing initialization ... // Register tools this.tools.set('search_cards', new SearchCardsTool(this.scryfallClient)); this.tools.set('get_card', new GetCardTool(this.scryfallClient)); this.tools.set('get_card_prices', new GetCardPricesTool(this.scryfallClient)); this.tools.set('random_card', new RandomCardTool(this.scryfallClient)); this.tools.set('search_sets', new SearchSetsTool(this.scryfallClient)); this.tools.set('build_scryfall_query', new BuildScryfallQueryTool(this.scryfallClient)); // New tool // ... other existing tools ... } // ... rest of existing code unchanged ... } ``` #### 4.2 Comprehensive Test Suite **File**: `tests/tools/build-scryfall-query.test.ts` ```typescript import { describe, it, expect, beforeEach, vi } from 'vitest'; import { BuildScryfallQueryTool } from '../../src/tools/build-scryfall-query.js'; import { ValidationError } from '../../src/types/mcp-types.js'; describe('BuildScryfallQueryTool', () => { let tool: BuildScryfallQueryTool; let mockScryfallClient: any; beforeEach(() => { mockScryfallClient = { searchCards: vi.fn() }; tool = new BuildScryfallQueryTool(mockScryfallClient); }); describe('Input Validation', () => { it('should reject empty natural query', async () => { const result = await tool.execute({ natural_query: '' }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Natural query cannot be empty'); }); it('should reject overly long natural query', async () => { const result = await tool.execute({ natural_query: 'a'.repeat(501) }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Natural query too long'); }); it('should reject invalid format', async () => { const result = await tool.execute({ natural_query: 'red creatures', format: 'invalid_format' }); expect(result.isError).toBe(true); }); it('should reject invalid max_results', async () => { const result = await tool.execute({ natural_query: 'red creatures', max_results: 200 }); expect(result.isError).toBe(true); }); it('should accept valid parameters', async () => { mockScryfallClient.searchCards.mockResolvedValue({ total_cards: 50, data: [] }); const result = await tool.execute({ natural_query: 'red creatures', format: 'modern', optimize_for: 'precision', max_results: 20 }); expect(result.isError).toBeUndefined(); }); }); describe('Natural Language Processing', () => { beforeEach(() => { mockScryfallClient.searchCards.mockResolvedValue({ total_cards: 25, data: [] }); }); it('should parse simple color + type query', async () => { const result = await tool.execute({ natural_query: 'red creatures' }); expect(result.content[0].text).toContain('c:r'); expect(result.content[0].text).toContain('t:creature'); }); it('should parse price constraints', async () => { const result = await tool.execute({ natural_query: 'blue spells under $10' }); expect(result.content[0].text).toContain('usd<=10'); }); it('should parse archetype constraints', async () => { const result = await tool.execute({ natural_query: 'red aggressive creatures' }); expect(result.content[0].text).toContain('pow>='); expect(result.content[0].text).toContain('cmc<='); }); it('should parse format constraints', async () => { const result = await tool.execute({ natural_query: 'creatures for modern', format: 'modern' }); expect(result.content[0].text).toContain('f:modern'); }); it('should parse guild names', async () => { const result = await tool.execute({ natural_query: 'azorius control spells' }); expect(result.content[0].text).toContain('c:wu'); }); it('should handle complex multi-concept queries', async () => { const result = await tool.execute({ natural_query: 'red and green aggressive creatures under $5 for modern with power 3 or greater' }); const queryText = result.content[0].text; expect(queryText).toContain('c:'); expect(queryText).toContain('t:creature'); expect(queryText).toContain('usd<=5'); expect(queryText).toContain('f:modern'); expect(queryText).toContain('pow>=3'); }); }); describe('Query Optimization', () => { beforeEach(() => { mockScryfallClient.searchCards.mockResolvedValue({ total_cards: 25, data: [] }); }); it('should optimize for precision by default', async () => { const result = await tool.execute({ natural_query: 'creatures' }); // Should add format restrictions for precision expect(result.content[0].text).toMatch(/f:(standard|modern|commander)/); }); it('should optimize for recall when requested', async () => { const result = await tool.execute({ natural_query: 'aggressive creatures', optimize_for: 'recall' }); // Should broaden power requirements expect(result.content[0].text).not.toContain('pow>=3'); }); it('should optimize for budget when requested', async () => { const result = await tool.execute({ natural_query: 'powerful creatures', optimize_for: 'budget' }); // Should add price constraints expect(result.content[0].text).toContain('usd<='); }); }); describe('Query Testing and Adjustment', () => { it('should broaden query when no results found', async () => { mockScryfallClient.searchCards.mockResolvedValue({ total_cards: 0, data: [] }); const result = await tool.execute({ natural_query: 'very specific creature type', test_query: true }); expect(result.content[0].text).toContain('broadened automatically'); }); it('should narrow query when too many results', async () => { mockScryfallClient.searchCards.mockResolvedValue({ total_cards: 1000, data: [] }); const result = await tool.execute({ natural_query: 'creatures', max_results: 20, test_query: true }); expect(result.content[0].text).toContain('narrowed automatically'); }); it('should handle query test failures gracefully', async () => { mockScryfallClient.searchCards.mockRejectedValue(new Error('API Error')); const result = await tool.execute({ natural_query: 'red creatures', test_query: true }); expect(result.isError).toBeUndefined(); // Should still return generated query even if test fails expect(result.content[0].text).toContain('Generated Scryfall Query:'); }); }); describe('Response Formatting', () => { beforeEach(() => { mockScryfallClient.searchCards.mockResolvedValue({ total_cards: 25, data: [] }); }); it('should include generated query', async () => { const result = await tool.execute({ natural_query: 'red creatures' }); expect(result.content[0].text).toContain('Generated Scryfall Query:'); expect(result.content[0].text).toContain('`c:r t:creature`'); }); it('should include explanation by default', async () => { const result = await tool.execute({ natural_query: 'red creatures' }); expect(result.content[0].text).toContain('Explanation:'); expect(result.content[0].text).toContain('Natural Language Mapping:'); }); it('should include confidence scores', async () => { const result = await tool.execute({ natural_query: 'red creatures' }); expect(result.content[0].text).toContain('Parsing Confidence:'); expect(result.content[0].text).toContain('Query Build Confidence:'); }); it('should include alternatives by default', async () => { const result = await tool.execute({ natural_query: 'red creatures' }); expect(result.content[0].text).toContain('Alternative Queries:'); }); it('should include usage instructions', async () => { const result = await tool.execute({ natural_query: 'red creatures' }); expect(result.content[0].text).toContain('Usage:'); expect(result.content[0].text).toContain('search_cards'); }); it('should exclude explanation when requested', async () => { const result = await tool.execute({ natural_query: 'red creatures', explain_mapping: false }); expect(result.content[0].text).not.toContain('Natural Language Mapping:'); }); it('should exclude alternatives when requested', async () => { const result = await tool.execute({ natural_query: 'red creatures', include_alternatives: false }); expect(result.content[0].text).not.toContain('Alternative Queries:'); }); }); describe('Low Confidence Handling', () => { it('should provide helpful suggestions for unclear queries', async () => { const result = await tool.execute({ natural_query: 'some cards maybe' }); // This should trigger low confidence handling expect(result.content[0].text).toContain('Low Confidence Parsing'); expect(result.content[0].text).toContain('Suggestions:'); expect(result.content[0].text).toContain('Examples of well-understood queries:'); }); it('should show what was understood from unclear input', async () => { const result = await tool.execute({ natural_query: 'red stuff for games' }); expect(result.content[0].text).toContain('What I understood:'); }); }); describe('Error Handling', () => { it('should handle Scryfall API errors gracefully', async () => { mockScryfallClient.searchCards.mockRejectedValue({ status: 422, message: 'Invalid query' }); const result = await tool.execute({ natural_query: 'red creatures' }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Scryfall API Error'); }); it('should handle rate limit errors', async () => { mockScryfallClient.searchCards.mockRejectedValue({ status: 429, message: 'Rate limited' }); const result = await tool.execute({ natural_query: 'red creatures' }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Rate limit exceeded'); }); }); }); ``` #### 4.3 Integration Tests **File**: `tests/integration/natural-language-integration.test.ts` ```typescript import { describe, it, expect } from 'vitest'; import { NaturalLanguageParser } from '../../src/natural-language/parser.js'; import { QueryBuilderEngine } from '../../src/natural-language/query-builder.js'; import { ConceptExtractor } from '../../src/natural-language/concept-extractor.js'; describe('Natural Language Processing Integration', () => { const parser = new NaturalLanguageParser(); const conceptExtractor = new ConceptExtractor(); // Mock ScryfallClient for testing const mockScryfallClient = { searchCards: async () => ({ total_cards: 25, data: [] }) }; const queryBuilder = new QueryBuilderEngine(conceptExtractor, mockScryfallClient as any); describe('End-to-End Query Generation', () => { it('should handle simple color + type queries', async () => { const parsed = parser.parse('red creatures'); const result = await queryBuilder.build(parsed, { optimize_for: 'precision' }); expect(result.query).toContain('c:r'); expect(result.query).toContain('t:creature'); expect(result.confidence).toBeGreaterThan(0.8); }); it('should handle complex archetype queries', async () => { const parsed = parser.parse('blue control spells for standard under $20'); const result = await queryBuilder.build(parsed, { optimize_for: 'precision', format: 'standard' }); expect(result.query).toContain('c:u'); expect(result.query).toContain('f:standard'); expect(result.query).toContain('usd<=20'); expect(result.query).toMatch(/(counterspell|removal|draw)/); }); it('should handle guild-based queries', async () => { const parsed = parser.parse('azorius cards for commander'); const result = await queryBuilder.build(parsed, { optimize_for: 'precision', format: 'commander' }); expect(result.query).toContain('c:wu'); expect(result.query).toContain('f:commander'); }); it('should handle power/toughness constraints', async () => { const parsed = parser.parse('aggressive creatures with power 3 or greater'); const result = await queryBuilder.build(parsed, { optimize_for: 'precision' }); expect(result.query).toContain('pow>=3'); expect(result.query).toContain('t:creature'); }); it('should handle price ranges', async () => { const parsed = parser.parse('cards between $5 and $15'); const result = await queryBuilder.build(parsed, { optimize_for: 'precision' }); expect(result.query).toContain('usd>=5'); expect(result.query).toContain('usd<=15'); }); }); describe('Query Optimization Strategies', () => { it('should add appropriate constraints for precision optimization', async () => { const parsed = parser.parse('creatures'); const result = await queryBuilder.build(parsed, { optimize_for: 'precision' }); // Should add format constraints and price limits expect(result.query).toMatch(/f:(standard|modern|commander)/); expect(result.query).toContain('usd<='); }); it('should broaden queries for recall optimization', async () => { const parsed = parser.parse('powerful creatures'); const precisionResult = await queryBuilder.build(parsed, { optimize_for: 'precision' }); const recallResult = await queryBuilder.build(parsed, { optimize_for: 'recall' }); // Recall version should be less restrictive expect(recallResult.query.length).toBeLessThanOrEqual(precisionResult.query.length); }); it('should add budget constraints for budget optimization', async () => { const parsed = parser.parse('good cards'); const result = await queryBuilder.build(parsed, { optimize_for: 'budget' }); expect(result.query).toContain('usd<='); }); }); describe('Conflict Resolution', () => { it('should resolve conflicting color requirements', async () => { const parsed = parser.parse('red and blue cards that are only red'); const result = await queryBuilder.build(parsed, { optimize_for: 'precision' }); // Should prioritize "only red" over "red and blue" expect(result.query).toContain('c:r'); expect(result.query).not.toContain('c:ur'); }); it('should handle overlapping archetype constraints', async () => { const parsed = parser.parse('aggressive control deck'); const result = await queryBuilder.build(parsed, { optimize_for: 'precision' }); // Should resolve to one coherent strategy expect(result.confidence).toBeGreaterThan(0.5); }); }); }); ``` ## Implementation Timeline and Milestones ### Week 1: Core Natural Language Processing - **Days 1-2**: Natural Language Parser with basic pattern engines - **Days 3-4**: Concept extraction and conflict resolution - **Days 5-7**: Parser integration and basic testing **Milestone**: Parse 20+ common natural language patterns with 80%+ accuracy ### Week 2: Query Building Engine - **Days 1-2**: Concept to Scryfall mapping system - **Days 3-4**: Query builder with optimization strategies - **Days 5-7**: Query testing and adjustment mechanisms **Milestone**: Generate valid Scryfall queries for 90%+ of parsed concepts ### Week 3: MCP Tool Implementation - **Days 1-3**: BuildScryfallQueryTool implementation - **Days 4-5**: Input validation and error handling - **Days 6-7**: Response formatting and user experience **Milestone**: Complete MCP tool with comprehensive error handling ### Week 4: Integration and Testing - **Days 1-2**: Server integration and tool registration - **Days 3-5**: Comprehensive test suite development - **Days 6-7**: Performance optimization and documentation **Milestone**: Production-ready tool with 95%+ test coverage ## Success Metrics ### Accuracy Metrics - **Parsing Accuracy**: 90%+ success rate for common natural language patterns - **Query Validity**: 95%+ of generated queries accepted by Scryfall API - **Result Relevance**: 80%+ of results match user intent (manual evaluation) ### Performance Metrics - **Response Time**: <3 seconds for complex natural language processing - **API Efficiency**: <2 API calls per query generation (test + optimize) - **Memory Usage**: <50MB peak memory during processing ### User Experience Metrics - **Confidence Scoring**: Accurate confidence scores (within 10% of manual evaluation) - **Error Handling**: Clear, actionable error messages for 100% of error cases - **Educational Value**: Explanations help users understand Scryfall syntax ## Risk Mitigation ### Technical Risks - **Complex Natural Language**: Start with common patterns, expand iteratively - **Scryfall API Changes**: Use existing validated patterns from enhanced validation - **Performance Issues**: Implement caching and optimize pattern matching ### Implementation Risks - **Scope Creep**: Focus on core 50 patterns before expanding - **Integration Complexity**: Leverage existing MCP tool patterns - **Testing Complexity**: Use data-driven testing with pattern libraries ## Future Enhancements ### Phase 2 Features (Post-MVP) 1. **Learning from Usage**: Track successful patterns to improve parsing 2. **Context Awareness**: Remember previous queries in session 3. **Advanced Archetypes**: Support for complex deck archetypes 4. **Multi-Language Support**: Support for non-English natural language ### Integration Opportunities 1. **Enhanced Validation**: Use with Feature 1 for complete validation pipeline 2. **Advanced Discovery**: Feed queries into Feature 3 for specialized searches 3. **Deck Building**: Integration with deck building tools and recommendations This comprehensive implementation plan provides a detailed roadmap for creating a sophisticated Natural Language Query Builder that will significantly enhance the Scryfall MCP server's accessibility and usability for AI assistants and users.

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