Skip to main content
Glama

Bar Assistant MCP Server

by zhdenny
MIT License
bar-assistant-client.tsβ€’19.5 kB
import axios, { AxiosInstance, AxiosResponse } from 'axios'; import { BarAssistantConfig, CocktailSearchResult, DetailedRecipe, SearchCocktailsParams, SimilarCocktail, InventoryStatus, ShoppingList, ShoppingListParams, InventoryCheckParams, BarIngredient, ApiError, CocktailCollection } from './types.js'; /** * Bar Assistant API Client * Handles all interactions with the Bar Assistant REST API */ export class BarAssistantClient { private client: AxiosInstance; private config: BarAssistantConfig; constructor(config: BarAssistantConfig) { this.config = config; this.client = axios.create({ baseURL: config.baseUrl, timeout: config.timeout || 30000, headers: { 'Authorization': `Bearer ${config.token}`, 'Content-Type': 'application/json', 'Accept': 'application/json', 'Bar-Assistant-Bar-Id': config.barId || '1', // Default bar ID }, }); // Add request/response interceptors for logging and error handling this.client.interceptors.request.use( (config) => { // API logging disabled to prevent MCP protocol interference return config; }, (error) => Promise.reject(error) ); this.client.interceptors.response.use( (response) => response, (error) => { const apiError: ApiError = { message: error.response?.data?.message || error.message || 'Unknown API error', status: error.response?.status || 500, errors: error.response?.data?.errors, }; return Promise.reject(apiError); } ); } /** * Test API connectivity and authentication */ async ping(): Promise<{ status: string; authenticated: boolean }> { try { // Try to get user profile first - this should work if authenticated const response = await this.client.get('/api/profile'); return { status: 'connected', authenticated: response.status === 200, }; } catch (error) { const apiError = error as ApiError; // If profile fails, try a simple endpoint that might not need bar context try { const fallbackResponse = await this.client.get('/api/ingredients?per_page=1'); return { status: 'connected', authenticated: fallbackResponse.status === 200, }; } catch (fallbackError) { return { status: 'error', authenticated: apiError.status !== 401, }; } } } /** * Search for cocktails with various filters */ async searchCocktails(params: SearchCocktailsParams = {}): Promise<CocktailSearchResult> { const searchParams = new URLSearchParams(); if (params.query) searchParams.append('filter[name]', params.query); if (params.ingredient) searchParams.append('filter[ingredient_name]', params.ingredient); if (params.base_spirit) searchParams.append('filter[base_spirit]', params.base_spirit); if (params.abv_min !== undefined) searchParams.append('filter[abv_min]', params.abv_min.toString()); if (params.abv_max !== undefined) searchParams.append('filter[abv_max]', params.abv_max.toString()); if (params.can_make !== undefined) searchParams.append('filter[can_make]', params.can_make ? '1' : '0'); if (params.limit) searchParams.append('per_page', params.limit.toString()); if (params.page) searchParams.append('page', params.page.toString()); // Always include ingredients and other related data - try different include formats searchParams.append('include', 'ingredients,tags,glass,method,images'); const response: AxiosResponse<CocktailSearchResult> = await this.client.get( `/api/cocktails?${searchParams.toString()}` ); return response.data; } /** * Get detailed cocktail recipe by ID */ async getCocktailRecipe(cocktailId: number): Promise<DetailedRecipe> { // Try different include parameters to get ingredients and instructions const response: AxiosResponse<{ data: DetailedRecipe }> = await this.client.get( `/api/cocktails/${cocktailId}?include=ingredients,instructions,tags,glass,method,images` ); return response.data.data; // Extract the nested data } /** * Find cocktails similar to the given cocktail ID */ async findSimilarCocktails(cocktailId: number, limit: number = 10): Promise<SimilarCocktail[]> { try { // First get the base cocktail to understand its profile const baseCocktail = await this.getCocktailRecipe(cocktailId); // Extract key ingredients for similarity matching - use short_ingredients for consistency const baseIngredients = (baseCocktail.short_ingredients || baseCocktail.ingredients)?.map((ing: any) => { const name = ing.ingredient?.name || ing.name || 'unknown'; return name.toLowerCase(); }) || []; // Get a broader set of cocktails to analyze for similarity const searchParams: SearchCocktailsParams = { limit: Math.min(limit * 10, 100), // Get many more results to properly analyze }; // Try multiple search strategies to find potentially similar cocktails const allPotentialMatches = new Map<number, any>(); // Strategy 1: Search by each key ingredient for (const ingredient of baseIngredients.slice(0, 3)) { // Top 3 ingredients try { const results = await this.searchCocktails({ ingredient: ingredient, limit: 50 }); results.data.forEach(cocktail => { if (cocktail.id !== cocktailId) { allPotentialMatches.set(cocktail.id, cocktail); } }); } catch (error) { // Continue if one ingredient search fails } } // Strategy 2: If we don't have enough matches, do a general search if (allPotentialMatches.size < limit * 3) { try { const generalResults = await this.searchCocktails(searchParams); generalResults.data.forEach(cocktail => { if (cocktail.id !== cocktailId) { allPotentialMatches.set(cocktail.id, cocktail); } }); } catch (error) { // General search failed, continue with what we have } } // Fetch full details for top candidates to get ingredient data // Limit to top 50 candidates to avoid too many API calls const candidateIds = Array.from(allPotentialMatches.keys()).slice(0, 50); const detailedCocktails = new Map<number, any>(); // Batch fetch details with parallel requests (groups of 10) const batchSize = 10; for (let i = 0; i < candidateIds.length; i += batchSize) { const batch = candidateIds.slice(i, i + batchSize); const detailPromises = batch.map(id => this.getCocktailRecipe(id).catch(err => { return null; }) ); const results = await Promise.all(detailPromises); results.forEach((cocktail, idx) => { if (cocktail) { detailedCocktails.set(batch[idx], cocktail); } }); } // Calculate similarity scores using detailed cocktail data const similarCocktails: SimilarCocktail[] = Array.from(detailedCocktails.values()) .map(cocktail => { // Extract ingredients from full cocktail details const cocktailIngredients = (cocktail.short_ingredients || cocktail.ingredients)?.map((ing: any) => { const name = ing.ingredient?.name || ing.name || 'unknown'; return name.toLowerCase(); }) || []; const similarity = this.calculateSimilarity(baseIngredients, cocktailIngredients); const reasons = this.getSimilarityReasons(baseCocktail, cocktail); return { cocktail, similarity_score: similarity, similarity_reasons: reasons, }; }) .sort((a, b) => (b.similarity_score || 0) - (a.similarity_score || 0)); // Filter and limit results (permissive threshold to allow diverse recommendations) const filtered = similarCocktails .filter(item => (item.similarity_score || 0) > 0.15) // Meaningful similarity threshold .slice(0, limit); return filtered; } catch (error) { throw error; } } /** * Check inventory status for given ingredients */ async checkInventory(params: InventoryCheckParams = {}): Promise<InventoryStatus> { try { // Get user's bar inventory const barId = this.config.barId || '1'; const response: AxiosResponse<{ data: BarIngredient[] }> = await this.client.get( `/api/bars/${barId}/ingredients` ); const availableIngredients = response.data.data; const availableNames = availableIngredients.map(bar => bar.ingredient.name.toLowerCase()); // If specific ingredients requested, filter for those let missingIngredients: any[] = []; if (params.ingredient_names && params.ingredient_names.length > 0) { const requestedIngredients = params.ingredient_names.map(name => name.toLowerCase()); missingIngredients = requestedIngredients .filter(name => !availableNames.includes(name)) .map(name => ({ name, id: 0, slug: name })); // Simplified structure } // Find cocktails that can be made with available ingredients const canMakeResults = await this.searchCocktails({ can_make: true, limit: 100 }); const canMakeCocktails = canMakeResults.data.map(c => c.id); return { available_ingredients: availableIngredients, missing_ingredients: missingIngredients as any[], // Simplified structure for missing ingredients can_make_cocktails: canMakeCocktails, }; } catch (error) { // Return empty inventory if bar doesn't exist or other error return { available_ingredients: [], missing_ingredients: (params.ingredient_names?.map(name => ({ id: 0, name, slug: name.toLowerCase(), images: [], cocktail_ingredient_substitutes: [], pivot: { id: 0, cocktail_id: 0, ingredient_id: 0, amount: 0, units: 'ml', optional: false, sort: 0 } })) || []) as any[], can_make_cocktails: [], }; } } /** * Generate shopping list for given cocktails */ async generateShoppingList(params: ShoppingListParams): Promise<ShoppingList> { try { const cocktails = await Promise.all( params.cocktail_ids.map(id => this.getCocktailRecipe(id)) ); // Get current inventory const inventory = await this.checkInventory(); const availableIngredients = inventory.available_ingredients.map( bar => bar.ingredient.name.toLowerCase() ); // Collect all required ingredients const requiredIngredients = new Map<string, { ingredient: any; totalAmount: number; units: string; cocktails: number[]; }>(); cocktails.forEach(cocktail => { cocktail.ingredients?.forEach(ingredient => { const name = ingredient.name.toLowerCase(); if (!availableIngredients.includes(name)) { const key = ingredient.id.toString(); const existing = requiredIngredients.get(key); if (existing) { existing.totalAmount += ingredient.pivot.amount || 0; existing.cocktails.push(cocktail.id); } else { requiredIngredients.set(key, { ingredient: ingredient, totalAmount: ingredient.pivot.amount || 0, units: ingredient.pivot.units || 'ml', cocktails: [cocktail.id], }); } } }); }); // Convert to shopping list format const items = Array.from(requiredIngredients.values()).map(item => ({ ingredient: item.ingredient, needed_amount: item.totalAmount, units: item.units, cocktails_requiring: item.cocktails, estimated_price: undefined, // Could be calculated if price data available })); return { items, cocktails_count: cocktails.length, total_estimated_cost: undefined, }; } catch (error) { console.error('Error generating shopping list:', error); throw error; } } /** * Search for cocktails by name with fuzzy matching */ async findCocktailByName(name: string): Promise<CocktailSearchResult> { // Try exact match first let results = await this.searchCocktails({ query: name, limit: 5 }); // If no exact match, try partial matching with different strategies if (results.data.length === 0) { // Try with partial name const words = name.split(' '); if (words.length > 1) { // Try first word only results = await this.searchCocktails({ query: words[0], limit: 5 }); } // If still no results, try searching by ingredient (user might be asking about ingredient-based cocktails) if (results.data.length === 0) { results = await this.searchCocktails({ ingredient: name, limit: 5 }); } } return results; } /** * Get user's cocktail collections */ async getCollections(): Promise<CocktailCollection[]> { try { const response: AxiosResponse<{ data: CocktailCollection[] }> = await this.client.get( '/api/collections?include=cocktails' ); return response.data.data || []; } catch (error) { return []; } } // Helper methods private extractBaseSpirit(ingredients: string[]): string | null { const spiritPatterns = [ { pattern: /(gin|juniper)/i, spirit: 'gin' }, { pattern: /(whiskey|whisky|bourbon|rye|scotch)/i, spirit: 'whiskey' }, { pattern: /(vodka)/i, spirit: 'vodka' }, { pattern: /(rum|rhum)/i, spirit: 'rum' }, { pattern: /(tequila|mezcal)/i, spirit: 'tequila' }, { pattern: /(brandy|cognac|armagnac)/i, spirit: 'brandy' }, ]; for (const ingredient of ingredients) { for (const { pattern, spirit } of spiritPatterns) { if (pattern.test(ingredient)) { return spirit; } } } return null; } private calculateSimilarity(ingredients1: string[], ingredients2: string[]): number { if (ingredients1.length === 0 || ingredients2.length === 0) return 0; const set1 = new Set(ingredients1.map(ing => this.normalizeIngredientName(ing))); const set2 = new Set(ingredients2.map(ing => this.normalizeIngredientName(ing))); // Calculate basic Jaccard similarity const intersection = new Set([...set1].filter(x => set2.has(x))); const union = new Set([...set1, ...set2]); const basicSimilarity = union.size > 0 ? intersection.size / union.size : 0; // Boost score for shared base spirits (more important) const spirits1 = ingredients1.filter(ing => this.isBaseSpirit(ing)); const spirits2 = ingredients2.filter(ing => this.isBaseSpirit(ing)); const sharedSpirits = spirits1.filter(spirit => spirits2.some(s => this.normalizeIngredientName(spirit) === this.normalizeIngredientName(s)) ); const spiritBonus = sharedSpirits.length > 0 ? 0.25 : 0; // Boost score for shared key modifiers (vermouth, bitters, etc.) const modifiers1 = ingredients1.filter(ing => this.isKeyModifier(ing)); const modifiers2 = ingredients2.filter(ing => this.isKeyModifier(ing)); const sharedModifiers = modifiers1.filter(mod => modifiers2.some(m => this.normalizeIngredientName(mod) === this.normalizeIngredientName(m)) ); const modifierBonus = sharedModifiers.length * 0.15; // Additional bonus for matching multiple ingredients const ingredientCountBonus = intersection.size >= 2 ? 0.1 : 0; return Math.min(1.0, basicSimilarity + spiritBonus + modifierBonus + ingredientCountBonus); } private normalizeIngredientName(ingredient: string): string { return ingredient.toLowerCase() .replace(/\s+/g, ' ') .replace(/,.*$/, '') // Remove everything after comma .replace(/\(.*\)/, '') // Remove parenthetical content .trim(); } private isBaseSpirit(ingredient: string): boolean { const normalized = this.normalizeIngredientName(ingredient); const spirits = ['gin', 'vodka', 'rum', 'whiskey', 'whisky', 'bourbon', 'rye', 'scotch', 'tequila', 'mezcal', 'brandy', 'cognac', 'armagnac', 'pisco', 'cachaΓ§a']; return spirits.some(spirit => normalized.includes(spirit)); } private isKeyModifier(ingredient: string): boolean { const normalized = this.normalizeIngredientName(ingredient); const modifiers = ['vermouth', 'campari', 'aperol', 'bitters', 'cointreau', 'triple sec', 'chartreuse', 'benedictine', 'maraschino', 'creme', 'liqueur']; return modifiers.some(modifier => normalized.includes(modifier)); } private getSimilarityReasons(cocktail1: DetailedRecipe, cocktail2: any): string[] { const reasons: string[] = []; // Compare base spirits - handle different ingredient structures const spirits1 = cocktail1.ingredients?.filter((ing: any) => { const name = ing.ingredient?.name || ing.name || ''; return this.extractBaseSpirit([name]) !== null; }) || []; const spirits2 = cocktail2.short_ingredients?.filter((ing: any) => { const name = ing.ingredient?.name || ing.name || ''; return this.extractBaseSpirit([name]) !== null; }) || []; if (spirits1.length > 0 && spirits2.length > 0) { const spirit1Item = spirits1[0] as any; const spirit2Item = spirits2[0] as any; const name1 = spirit1Item.ingredient?.name || spirit1Item.name || ''; const name2 = spirit2Item.ingredient?.name || spirit2Item.name || ''; const spirit1 = this.extractBaseSpirit([name1]); const spirit2 = this.extractBaseSpirit([name2]); if (spirit1 === spirit2) { reasons.push(`Same base spirit: ${spirit1}`); } } // Compare common ingredients - handle different ingredient structures const ingredients1 = cocktail1.ingredients?.map((ing: any) => { const name = ing.ingredient?.name || ing.name || 'unknown'; return name.toLowerCase(); }) || []; const ingredients2 = cocktail2.short_ingredients?.map((ing: any) => { const name = ing.ingredient?.name || ing.name || 'unknown'; return name.toLowerCase(); }) || []; const common = ingredients1.filter(ing => ingredients2.includes(ing)); if (common.length > 0) { reasons.push(`Shared ingredients: ${common.slice(0, 3).join(', ')}`); } // Compare methods if available if (cocktail1.method?.name && cocktail2.method?.name) { if (cocktail1.method.name === cocktail2.method.name) { reasons.push(`Same preparation method: ${cocktail1.method.name}`); } } // Compare glass types if available if (cocktail1.glass?.name && cocktail2.glass?.name) { if (cocktail1.glass.name === cocktail2.glass.name) { reasons.push(`Same glass type: ${cocktail1.glass.name}`); } } return reasons; } }

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/zhdenny/bar-assistant-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server