search_venues
Find nightlife venues by city, date, area, and genre. Filter by VIP booking support and search with text queries to discover events and clubs.
Instructions
Search nightlife venues by city/date window and optional area, genre, text query, and VIP booking support.
Input Schema
TableJSON Schema
| Name | Required | Description | Default |
|---|---|---|---|
| city | No | tokyo | |
| date | No | ||
| area | No | ||
| genre | No | ||
| query | No | ||
| vip_booking_supported_only | No | ||
| limit | No | ||
| offset | No |
Implementation Reference
- src/services/venues.ts:791-1173 (handler)The core implementation of the search_venues logic, which queries venue events, handles filtering (including fuzzy search), and returns venue summaries.
export async function searchVenues( supabase: SupabaseClient, config: AppConfig, input: SearchVenuesInput, ): Promise<SearchVenuesOutput> { const citySlug = normalizeCity(input.city, config.defaultCity); const city = await getCityContext(supabase, citySlug, config.defaultCountryCode); if (!city) { return { city: citySlug, date_filter: input.date || null, venues: [], unavailable_city: await unavailableCityPayload( supabase, citySlug, config.nightlifeBaseUrl, config.topLevelCities, ), }; } const now = new Date(); let parsedDate: ReturnType<typeof parseDateFilter>; try { parsedDate = parseDateFilter(input.date, now, city.timezone, city.serviceDayCutoffTime); } catch (error) { const message = error instanceof Error ? error.message : "Invalid date filter."; throw new NightlifeError("INVALID_DATE_FILTER", message); } const genreEventIds = input.genre ? await resolveGenreEventIds(supabase, input.genre) : null; if (genreEventIds && genreEventIds.size === 0) { return { city: city.slug, date_filter: parsedDate?.label || null, venues: [], unavailable_city: null, }; } const currentServiceDate = getCurrentServiceDate(now, city.timezone, city.serviceDayCutoffTime); const startServiceDate = parsedDate?.startServiceDate || currentServiceDate; const endServiceDateExclusive = parsedDate?.endServiceDateExclusive || addDaysToIsoDate(currentServiceDate, 31); const window = serviceDateWindowToUtc( startServiceDate, endServiceDateExclusive, city.timezone, city.serviceDayCutoffTime, ); const { data, error } = await supabase .from("event_occurrences") .select(OCCURRENCE_SELECT) .eq("published", true) .eq("city_id", city.id) .not("venue_id", "is", null) .gte("start_at", window.startIso) .lt("start_at", window.endIso) .order("start_at", { ascending: true }) .range(0, 1999); if (error) { throw new NightlifeError("DB_QUERY_FAILED", "Failed to fetch venue events.", { cause: error.message, }); } const queryNeedle = input.query ? sanitizeIlike(input.query).toLowerCase() : ""; const areaNeedle = input.area ? input.area.trim().toLowerCase() : ""; const vipBookingSupportedOnly = input.vip_booking_supported_only === true; let occurrences = (data || []) as unknown as EventOccurrenceRow[]; if (genreEventIds) { occurrences = occurrences.filter((row) => genreEventIds.has(row.id)); } const vipHourOccurrences = genreEventIds === null ? ( await fetchVipVenuesWithHours(supabase, city.id) ).flatMap((venue) => buildVipHoursSyntheticOccurrences( venue, startServiceDate, endServiceDateExclusive, city.timezone, ), ) : []; const effectiveOccurrences = [...occurrences, ...vipHourOccurrences]; if (effectiveOccurrences.length === 0) { return { city: city.slug, date_filter: parsedDate?.label || null, venues: [], unavailable_city: null, }; } const genresByEvent = await fetchGenresByEvent( supabase, occurrences.map((row) => row.id), ); const aggregates = new Map< string, { venue: VenueRow; nextEventDate: string | null; eventCount: number; genreCounts: Map<string, number>; eventNames: string[]; } >(); for (const row of effectiveOccurrences) { const venue = firstRelation(row.venue); if (!venue) { continue; } const venueId = venue.id || row.venue_id; if (!venueId) { continue; } if (vipBookingSupportedOnly && venue.vip_booking_enabled !== true) { continue; } if ( areaNeedle && !hasNeedle( areaNeedle, venue.city, venue.city_en, venue.city_ja, venue.address, venue.address_en, venue.address_ja, ) ) { continue; } const genreNames = genresByEvent.get(row.id) || []; if ( queryNeedle && !hasNeedle( queryNeedle, venue.name, venue.name_en, venue.name_ja, venue.city, venue.city_en, venue.city_ja, venue.address, venue.address_en, venue.address_ja, row.name_en, maybeJa(row.name_i18n), row.description_en, maybeJa(row.description_i18n), ) && !genreNames.some((name) => name.toLowerCase().includes(queryNeedle)) ) { continue; } const current = aggregates.get(venueId) || { venue, nextEventDate: null, eventCount: 0, genreCounts: new Map<string, number>(), eventNames: [], }; current.eventCount += 1; const eventDate = primaryDay(row.occurrence_days)?.start_at || row.start_at || null; if (eventDate && (!current.nextEventDate || eventDate < current.nextEventDate)) { current.nextEventDate = eventDate; } for (const genre of genreNames) { current.genreCounts.set(genre, (current.genreCounts.get(genre) || 0) + 1); } current.eventNames.push(eventName(row)); aggregates.set(venueId, current); } const summaries: VenueSummary[] = Array.from(aggregates.entries()).map(([venueId, value]) => { const sortedGenres = Array.from(value.genreCounts.entries()) .sort((a, b) => { if (a[1] !== b[1]) { return b[1] - a[1]; } return a[0].localeCompare(b[0]); }) .map(([genre]) => genre) .slice(0, 5); return { venue_id: venueId, name: venueName(value.venue), area: venueArea(value.venue), address: venueAddress(value.venue), website: value.venue.website || null, image_url: value.venue.image_url || null, vip_booking_supported: value.venue.vip_booking_enabled === true, upcoming_event_count: value.eventCount, next_event_date: value.nextEventDate, genres: sortedGenres, nlt_url: buildVenueUrl(config.nightlifeBaseUrl, city.slug, venueId), }; }); // Pass 2: Fuzzy RPC fallback when pass 1 found nothing and a text query was provided if (shouldAttemptFuzzy(summaries.length, queryNeedle, genreEventIds)) { const fuzzyIds = await fuzzyVenueIds(supabase, city.id, input.query || ""); if (fuzzyIds.length > 0) { // Fetch event occurrences for fuzzy-matched venues within the same date window const { data: fuzzyData, error: fuzzyError } = await supabase .from("event_occurrences") .select(OCCURRENCE_SELECT) .eq("published", true) .eq("city_id", city.id) .in("venue_id", fuzzyIds) .gte("start_at", window.startIso) .lt("start_at", window.endIso) .order("start_at", { ascending: true }) .range(0, 1999); if (fuzzyError) { throw new NightlifeError("DB_QUERY_FAILED", "Failed to fetch fuzzy venue events.", { cause: fuzzyError.message, }); } // Merge VIP hours synthetic occurrences for fuzzy venues const fuzzyIdSet = new Set(fuzzyIds); const fuzzyVipHours = vipHourOccurrences.filter( (row) => row.venue_id && fuzzyIdSet.has(row.venue_id), ); const fuzzyOccurrences = [ ...((fuzzyData || []) as unknown as EventOccurrenceRow[]), ...fuzzyVipHours, ]; if (fuzzyOccurrences.length > 0) { // Fetch genres for fuzzy occurrences (only real UUIDs, not synthetic VIP IDs) const fuzzyGenresByEvent = await fetchGenresByEvent( supabase, fuzzyOccurrences.filter((r) => UUID_RE.test(r.id)).map((r) => r.id), ); // Aggregate fuzzy occurrences into VenueSummary objects. // Same aggregation loop as pass 1, but WITHOUT the queryNeedle filter // (we already know these venues match the query via the RPC). const fuzzyAggregates = new Map< string, { venue: VenueRow; nextEventDate: string | null; eventCount: number; genreCounts: Map<string, number>; eventNames: string[]; } >(); for (const row of fuzzyOccurrences) { const venue = firstRelation(row.venue); if (!venue) continue; const venueId = venue.id || row.venue_id; if (!venueId) continue; if (vipBookingSupportedOnly && venue.vip_booking_enabled !== true) continue; if ( areaNeedle && !hasNeedle( areaNeedle, venue.city, venue.city_en, venue.city_ja, venue.address, venue.address_en, venue.address_ja, ) ) continue; const genreNames = fuzzyGenresByEvent.get(row.id) || []; const current = fuzzyAggregates.get(venueId) || { venue, nextEventDate: null, eventCount: 0, genreCounts: new Map<string, number>(), eventNames: [], }; current.eventCount += 1; const eventDate = primaryDay(row.occurrence_days)?.start_at || row.start_at || null; if (eventDate && (!current.nextEventDate || eventDate < current.nextEventDate)) { current.nextEventDate = eventDate; } for (const genre of genreNames) { current.genreCounts.set(genre, (current.genreCounts.get(genre) || 0) + 1); } current.eventNames.push(eventName(row)); fuzzyAggregates.set(venueId, current); } // Build fuzzy summaries preserving RPC word_similarity order (VEN-03). // fuzzyIds is ordered by word_similarity DESC from the RPC. // The aggregation Map may have different insertion order (occurrence-based), // so we explicitly sort by the fuzzyIds position to preserve similarity ranking. const fuzzyIdOrder = new Map(fuzzyIds.map((id, idx) => [id, idx])); const fuzzySummaries: VenueSummary[] = Array.from(fuzzyAggregates.entries()) .sort(([idA], [idB]) => { const orderA = fuzzyIdOrder.get(idA) ?? Number.MAX_SAFE_INTEGER; const orderB = fuzzyIdOrder.get(idB) ?? Number.MAX_SAFE_INTEGER; return orderA - orderB; }) .map(([venueId, value]) => { const sortedGenres = Array.from(value.genreCounts.entries()) .sort((a, b) => { if (a[1] !== b[1]) return b[1] - a[1]; return a[0].localeCompare(b[0]); }) .map(([genre]) => genre) .slice(0, 5); return { venue_id: venueId, name: venueName(value.venue), area: venueArea(value.venue), address: venueAddress(value.venue), website: value.venue.website || null, image_url: value.venue.image_url || null, vip_booking_supported: value.venue.vip_booking_enabled === true, upcoming_event_count: value.eventCount, next_event_date: value.nextEventDate, genres: sortedGenres, nlt_url: buildVenueUrl(config.nightlifeBaseUrl, city.slug, venueId), }; }); // IMPORTANT: Return fuzzy results directly, bypassing rankVenueSummaries(). // rankVenueSummaries() re-ranks by event activity which would destroy the RPC's // word_similarity ordering. For fuzzy results, similarity-based ranking is correct: // a hotel concierge asking "find me celavi" should see the best fuzzy match first. const offset = coerceOffset(input.offset); const limit = coerceLimit(input.limit); const paged = fuzzySummaries.slice(offset, offset + limit); return { city: city.slug, date_filter: parsedDate?.label || null, venues: paged, unavailable_city: null, }; } } } const offset = coerceOffset(input.offset); const limit = coerceLimit(input.limit); const paged = rankVenueSummaries(summaries).slice(offset, offset + limit); return { city: city.slug, date_filter: parsedDate?.label || null, venues: paged, unavailable_city: null, }; } - src/tools/venues.ts:110-132 (registration)Registration of the 'search_venues' MCP tool, which links the tool definition and Zod schemas to the service handler.
server.registerTool( "search_venues", { description: "Search nightlife venues by city/date window and optional area, genre, text query, and VIP booking support.", inputSchema: { city: z.string().default(deps.config.defaultCity), date: z.string().optional(), area: z.string().optional(), genre: z.string().optional(), query: z.string().optional(), vip_booking_supported_only: z.boolean().optional(), limit: z.number().int().min(1).max(20).default(10), offset: z.number().int().min(0).default(0), }, outputSchema: searchVenuesOutputSchema, }, async (args) => runTool( "search_venues", searchVenuesOutputSchema, async () => searchVenues(deps.supabase, deps.config, args), ), ); - src/tools/venues.ts:29-34 (schema)Zod schema definition for the output of the 'search_venues' tool.
const searchVenuesOutputSchema = z.object({ city: z.string(), date_filter: z.string().nullable(), venues: z.array(venueSummarySchema), unavailable_city: cityUnavailableSchema.nullable(), });