Skip to main content
Glama

Scryfall MCP Server

by bmurdock
query-builder.ts16.1 kB
/** * @fileoverview Query Builder Engine for Natural Language Processing * * This module assembles Scryfall queries from extracted concept mappings, * applies optimization strategies, and generates explanations and alternatives. */ import { ParsedQuery, ConceptMapping, BuildOptions, BuildResult, AlternativeQuery, QueryOptimization } from './types.js'; import { ConceptExtractor } from './concept-extractor.js'; import { ScryfallClient } from '../services/scryfall-client.js'; import { mcpLogger } from '../services/logger.js'; /** * Builds optimized Scryfall queries from natural language concepts */ export class QueryBuilderEngine { constructor( private readonly conceptExtractor: ConceptExtractor, private readonly scryfallClient: ScryfallClient ) {} /** * Build a complete Scryfall query from parsed natural language */ 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 }; } /** * Build the base query from concept mappings */ 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(' '); } /** * Group mappings by operator */ private groupMappingsByOperator(mappings: ConceptMapping[]): Map<string, ConceptMapping[]> { 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); } return grouped; } /** * Build query part for a specific operator */ 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); const oracleQueries = values.map(v => `o:"${v}"`); return `(${oracleQueries.join(' OR ')})`; } else if (operator === 't') { // Type searches can be combined with OR const values = mappings.map(m => m.value); const typeQueries = values.map(v => `t:${v}`); return `(${typeQueries.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, mappings[0] ); const prefix = best.negation ? '-' : ''; const comparison = best.comparison || ''; return `${prefix}${operator}:${comparison}${best.value}`; } /** * Build numeric range queries (CMC, power, toughness, prices) */ 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, exactMappings[0] ); return `${operator}:${best.value}`; } const parts: string[] = []; if (minMappings.length > 0) { const best = minMappings.reduce((a, b) => a.confidence > b.confidence ? a : b, minMappings[0] ); parts.push(`${operator}:${best.comparison}${best.value}`); } if (maxMappings.length > 0) { const best = maxMappings.reduce((a, b) => a.confidence > b.confidence ? a : b, maxMappings[0] ); parts.push(`${operator}:${best.comparison}${best.value}`); } return parts.join(' '); } /** * Apply format constraints to the query */ private applyFormat(query: string, format?: string): string { if (!format) return query; const formatPart = `f:${format}`; return query ? `${query} ${formatPart}` : formatPart; } /** * Apply optimization strategy to the query */ private applyOptimization(query: string, strategy: string): string { switch (strategy) { case 'precision': return this.optimizeForPrecision(query); case 'recall': return this.optimizeForRecall(query); case 'discovery': return this.optimizeForDiscovery(query); case 'budget': return this.optimizeForBudget(query); default: return query; } } /** * Optimize query for precision (fewer, more relevant results) */ private optimizeForPrecision(query: string): string { 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 const priceRegex = /(?:usd|eur|tix):/; if (!priceRegex.test(query)) { optimized += ' usd<=50'; } return optimized; } /** * Optimize query for recall (broader search) */ private optimizeForRecall(query: string): string { 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; } /** * Optimize query for discovery (interesting cards) */ private optimizeForDiscovery(query: string): string { let optimized = query; // Add interesting constraints for exploration if (!query.includes('is:')) { optimized += ' (is:unique OR is:reserved OR is:promo)'; } return optimized; } /** * Optimize query for budget (cost-effective cards) */ private optimizeForBudget(query: string): string { let optimized = query; // Add budget-friendly constraints const budgetPriceRegex = /(?:usd|eur|tix):/; if (!budgetPriceRegex.test(query)) { optimized += ' usd<=5'; } return optimized; } /** * Get related types for broadening searches */ private getRelatedTypes(type: string): string[] { const relatedMap = new Map([ ['creature', ['planeswalker']], // Both are threats ['instant', ['sorcery']], // Both are spells ['sorcery', ['instant']], // Both are spells ['artifact', ['enchantment']], // Both are permanents ['enchantment', ['artifact']] // Both are permanents ]); return relatedMap.get(type) || []; } /** * Test query and adjust if needed */ 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; const totalCards = testResult.total_cards ?? 0; if (totalCards === 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 (totalCards > (options.max_results || 20) * 10) { // Too many results - narrow the query adjustedQuery = this.narrowQuery(query); optimizations.push({ type: 'narrowing', reason: `Original query returned ${totalCards} results`, change: `${query} → ${adjustedQuery}` }); } return { query: adjustedQuery, optimizations }; } catch (error) { // If query fails, log the error and return original mcpLogger.warn({ operation: 'query_test', error: error instanceof Error ? error.message : String(error) }, 'Query testing failed'); return { query, optimizations: [] }; } } /** * Broaden a query that returns no results */ private broadenQuery(query: string): string { // Remove restrictive constraints let broadened = query; // Remove exact CMC constraints broadened = broadened.replace(/cmc:=(\d+)/, 'cmc<=$1'); // Remove power constraints broadened = broadened.replace(/pow:>=(\d+)/, (match, value) => { const newValue = Math.max(1, parseInt(value) - 1); return `pow:>=${newValue}`; }); // Remove price constraints if too restrictive broadened = broadened.replace(/usd:<=(\d+)/, (match, value) => { const newValue = parseInt(value) * 2; return `usd:<=${newValue}`; }); return broadened; } /** * Narrow a query that returns too many results */ private narrowQuery(query: string): string { let narrowed = query; // Add format constraint if missing if (!query.includes('f:')) { narrowed += ' f:modern'; } // Add price constraint if missing const narrowPriceRegex = /(?:usd|eur|tix):/; if (!narrowPriceRegex.test(query)) { narrowed += ' usd<=20'; } return narrowed; } /** * Generate explanation of the query */ 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(', '); } /** * Explain color constraints */ private explainColorConstraints(mappings: ConceptMapping[]): string { const colorNames = mappings.map(m => this.getColorName(m.value)).join(' and '); return `Cards that are ${colorNames}`; } /** * Explain type constraints */ private explainTypeConstraints(mappings: ConceptMapping[]): string { const types = mappings.map(m => m.value).join(' or '); return `${types} cards`; } /** * Explain function constraints */ private explainFunctionConstraints(mappings: ConceptMapping[]): string { const functions = mappings.map(m => m.value).join(' or '); return `with ${functions} effects`; } /** * Explain price constraints */ private explainPriceConstraints(mappings: ConceptMapping[]): string { const prices = mappings.map(m => `${m.comparison}$${m.value}`).join(' and '); return `priced ${prices}`; } /** * Explain stat constraints */ private explainStatConstraints(mappings: ConceptMapping[]): string { const stats = mappings.map(m => `${m.operator} ${m.comparison}${m.value}`).join(' and '); return `with ${stats}`; } /** * Get color name from code */ private getColorName(code: string): string { const colorMap = new Map([ ['w', 'white'], ['u', 'blue'], ['b', 'black'], ['r', 'red'], ['g', 'green'], ['c', 'colorless'], ['m', 'multicolor'] ]); return colorMap.get(code) || code; } /** * Generate alternative queries */ 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 }); } } return alternatives.slice(0, 3); // Limit to top 3 alternatives } /** * Calculate build confidence */ private calculateBuildConfidence( parsed: ParsedQuery, mappings: ConceptMapping[], testedQuery: any ): number { let confidence = parsed.confidence; // Boost confidence if query was successfully tested if (testedQuery.optimizations.length === 0) { confidence += 0.1; } // Reduce confidence for each optimization needed confidence -= testedQuery.optimizations.length * 0.05; return Math.max(0, Math.min(1, confidence)); } }

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