Skip to main content
Glama
alcylu

Nightlife Search

by alcylu

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
NameRequiredDescriptionDefault
cityNotokyo
dateNo
areaNo
genreNo
queryNo
vip_booking_supported_onlyNo
limitNo
offsetNo

Implementation Reference

  • 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,
      };
    }
  • 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),
      ),
    );
  • 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(),
    });

Latest Blog Posts

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/alcylu/nightlife-mcp'

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