search-alternatives.ts•8.35 kB
import { ScryfallClient } from '../services/scryfall-client.js';
import { ValidationError, ScryfallAPIError } from '../types/mcp-types.js';
import { formatSearchResultsAsText } from '../utils/formatters.js';
/**
* MCP Tool for finding budget alternatives, upgrades, or functionally similar cards
*/
export class SearchAlternativesTool {
readonly name = 'search_alternatives';
readonly description = 'Find budget alternatives, upgrades, or functionally similar cards';
readonly inputSchema = {
type: 'object' as const,
properties: {
target_card: {
type: 'string',
description: 'Card to find alternatives for'
},
direction: {
type: 'string',
enum: ['cheaper', 'upgrade', 'similar'],
description: 'Type of alternative to find'
},
format: {
type: 'string',
enum: ['standard', 'modern', 'legacy', 'vintage', 'commander', 'pioneer'],
description: 'Format legality requirement'
},
max_price: {
type: 'number',
minimum: 0,
description: 'Maximum price constraint'
},
min_price: {
type: 'number',
minimum: 0,
description: 'Minimum price constraint'
},
preserve_function: {
type: 'boolean',
default: true,
description: 'Maintain similar functionality'
},
limit: {
type: 'number',
default: 10,
minimum: 1,
maximum: 50,
description: 'Number of alternatives to return'
}
},
required: ['target_card', 'direction']
};
constructor(private readonly scryfallClient: ScryfallClient) {}
/**
* Validate parameters
*/
private validateParams(args: unknown): {
target_card: string;
direction: string;
format?: string;
max_price?: number;
min_price?: number;
preserve_function: boolean;
limit: number;
} {
if (!args || typeof args !== 'object') {
throw new ValidationError('Invalid parameters');
}
const params = args as any;
if (!params.target_card || typeof params.target_card !== 'string') {
throw new ValidationError('Target card is required and must be a string');
}
if (!params.direction || typeof params.direction !== 'string') {
throw new ValidationError('Direction is required and must be a string');
}
const validDirections = ['cheaper', 'upgrade', 'similar'];
if (!validDirections.includes(params.direction)) {
throw new ValidationError(`Direction must be one of: ${validDirections.join(', ')}`);
}
if (params.format) {
const validFormats = ['standard', 'modern', 'legacy', 'vintage', 'commander', 'pioneer'];
if (!validFormats.includes(params.format)) {
throw new ValidationError(`Format must be one of: ${validFormats.join(', ')}`);
}
}
if (params.max_price !== undefined) {
if (typeof params.max_price !== 'number' || params.max_price < 0) {
throw new ValidationError('Max price must be a non-negative number');
}
}
if (params.min_price !== undefined) {
if (typeof params.min_price !== 'number' || params.min_price < 0) {
throw new ValidationError('Min price must be a non-negative number');
}
}
const preserveFunction = params.preserve_function ?? true;
if (typeof preserveFunction !== 'boolean') {
throw new ValidationError('Preserve function must be a boolean');
}
const limit = params.limit || 10;
if (typeof limit !== 'number' || limit < 1 || limit > 50) {
throw new ValidationError('Limit must be a number between 1 and 50');
}
return {
target_card: params.target_card.trim(),
direction: params.direction,
format: params.format,
max_price: params.max_price,
min_price: params.min_price,
preserve_function: preserveFunction,
limit
};
}
async execute(args: unknown) {
try {
// Validate parameters
const params = this.validateParams(args);
// First, get the target card to analyze its properties
let targetCard;
try {
targetCard = await this.scryfallClient.getCard({ identifier: params.target_card });
} catch (error) {
if (error instanceof ScryfallAPIError && error.status === 404) {
return {
content: [
{
type: 'text',
text: `Target card not found: "${params.target_card}". Please check the card name.`
}
],
isError: true
};
}
throw error;
}
// Build search query for alternatives
const query = this.buildAlternativesQuery(targetCard, params);
// Execute search
const results = await this.scryfallClient.searchCards({
query,
limit: params.limit,
order: this.getOrderForDirection(params.direction)
});
// Filter out the original card from results
const filteredResults = {
...results,
data: results.data.filter(card => card.id !== targetCard.id)
};
// Format results
let responseText = `**Alternatives for ${targetCard.name}** (${params.direction}):\n\n`;
if (filteredResults.data.length === 0) {
responseText += `No ${params.direction} alternatives found for "${targetCard.name}"`;
if (params.format) {
responseText += ` in ${params.format}`;
}
responseText += '. Try adjusting your search criteria.';
} else {
responseText += formatSearchResultsAsText(filteredResults);
}
return {
content: [
{
type: 'text',
text: responseText
}
]
};
} catch (error) {
// Handle validation errors
if (error instanceof ValidationError) {
return {
content: [
{
type: 'text',
text: `Validation error: ${error.message}`
}
],
isError: true
};
}
// Generic error handling
return {
content: [
{
type: 'text',
text: `Unexpected error: ${error instanceof Error ? error.message : 'Unknown error occurred'}`
}
],
isError: true
};
}
}
/**
* Build search query for finding alternatives
*/
private buildAlternativesQuery(targetCard: any, params: {
direction: string;
format?: string;
max_price?: number;
min_price?: number;
preserve_function: boolean;
}): string {
let query = '';
// Add format constraint
if (params.format) {
query += `f:${params.format} `;
}
// Add functional similarity constraints if requested
if (params.preserve_function) {
// Match primary type
const primaryType = targetCard.type_line.split(' ')[0];
query += `t:${primaryType} `;
// Match mana value (within 1)
const cmc = targetCard.cmc || 0;
if (cmc > 0) {
query += `(cmc:${cmc} OR cmc:${Math.max(0, cmc - 1)} OR cmc:${cmc + 1}) `;
}
}
// Add price constraints based on direction
const targetPrice = parseFloat((targetCard.prices as any).usd || '0');
if (params.direction === 'cheaper' && targetPrice > 0) {
const maxPrice = params.max_price || targetPrice * 0.8; // 20% cheaper by default
query += `usd<=${maxPrice} `;
} else if (params.direction === 'upgrade' && targetPrice > 0) {
const minPrice = params.min_price || targetPrice * 1.2; // 20% more expensive by default
query += `usd>=${minPrice} `;
}
// Add explicit price constraints
if (params.max_price !== undefined) {
query += `usd<=${params.max_price} `;
}
if (params.min_price !== undefined) {
query += `usd>=${params.min_price} `;
}
return query.trim();
}
/**
* Get sort order based on direction
*/
private getOrderForDirection(direction: string): string {
switch (direction) {
case 'cheaper':
return 'usd'; // Sort by price ascending (cheapest first)
case 'upgrade':
return 'edhrec'; // Sort by popularity for upgrades
case 'similar':
return 'name'; // Alphabetical for similar cards
default:
return 'name';
}
}
}