Skip to main content
Glama
search.ts13.3 kB
import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; import { MetabaseApiClient } from '../api.js'; import { ErrorCode, McpError } from '../types/core.js'; import { ValidationErrorFactory } from '../utils/errorFactory.js'; import { handleApiError, validatePositiveInteger, validateEnumValue, formatJson, } from '../utils/index.js'; export async function handleSearch( request: CallToolRequest, requestId: string, apiClient: MetabaseApiClient, logDebug: (message: string, data?: unknown) => void, logInfo: (message: string, data?: unknown) => void, logWarn: (message: string, data?: unknown, error?: Error) => void, logError: (message: string, error: unknown) => void ) { const searchQuery = request.params?.arguments?.query as string; const models = (request.params?.arguments?.models as string[]) || ['card', 'dashboard']; const maxResults = (request.params?.arguments?.max_results as number) ?? 20; const searchNativeQuery = (request.params?.arguments?.search_native_query as boolean) || false; const includeDashboardQuestions = (request.params?.arguments?.include_dashboard_questions as boolean) ?? false; const ids = request.params?.arguments?.ids as number[] | undefined; const archived = request.params?.arguments?.archived as boolean | undefined; const databaseId = request.params?.arguments?.database_id as number | undefined; const verified = request.params?.arguments?.verified as boolean | undefined; // Validate positive integer parameters if (maxResults !== undefined) { validatePositiveInteger(maxResults, 'max_results', requestId, logWarn); if (maxResults > 50) { logWarn('max_results exceeds maximum allowed value', { requestId, maxResults }); throw new McpError(ErrorCode.InvalidParams, 'max_results must be at most 50'); } } if (databaseId !== undefined) { validatePositiveInteger(databaseId, 'database_id', requestId, logWarn); } if (ids !== undefined && ids.length > 0) { ids.forEach((id, index) => { validatePositiveInteger(id, `ids[${index}]`, requestId, logWarn); }); } // Validate models parameter with case insensitive handling if (models && models.length > 0) { const supportedModels = ['card', 'dashboard', 'table', 'database', 'collection'] as const; models.forEach((model, index) => { validateEnumValue(model, supportedModels, `models[${index}]`, requestId, logWarn); }); } // Updated validation: allow searching without query/id if database_id is provided if (!searchQuery && (!ids || ids.length === 0) && !databaseId) { logWarn('Missing query, ids, or database_id parameter in search request', { requestId }); throw ValidationErrorFactory.invalidParameter( 'query_or_ids_or_database_id', 'none provided', 'Either search query, ids parameter, or database_id is required' ); } // Validate that only one search method is used if (searchQuery && ids && ids.length > 0) { logWarn('Cannot use both query and ids parameters simultaneously', { requestId }); throw ValidationErrorFactory.invalidParameter( 'query_and_ids', 'both provided', 'Cannot use both query and ids parameters - use either search query OR ids, not both' ); } // Validate ids usage - only allowed with single model if (ids && ids.length > 0 && models.length > 1) { logWarn('ids parameter can only be used with a single model type', { requestId }); throw new McpError( ErrorCode.InvalidParams, 'ids parameter can only be used when searching a single model type' ); } // Validate ids usage - not allowed with 'table' model if (ids && ids.length > 0 && models.includes('table')) { logWarn('ids parameter cannot be used with table model', { requestId }); throw new McpError( ErrorCode.InvalidParams, 'ids parameter cannot be used when searching for tables - use query or database_id instead' ); } // Validate database searches - only allow query parameter when searching solely for databases if (models.length === 1 && models[0] === 'database') { if (ids && ids.length > 0) { logWarn('ids parameter cannot be used when searching solely for databases', { requestId }); throw new McpError( ErrorCode.InvalidParams, 'ids parameter cannot be used when searching for databases - use query instead' ); } if (databaseId) { logWarn('database_id parameter cannot be used when searching solely for databases', { requestId, }); throw new McpError( ErrorCode.InvalidParams, 'database_id parameter cannot be used when searching for databases - use query instead' ); } } // Validate database model exclusivity - database searches must be exclusive if (models.includes('database') && models.length > 1) { logWarn('database model cannot be mixed with other models', { requestId }); throw new McpError( ErrorCode.InvalidParams, 'database model cannot be combined with other model types - search for databases separately' ); } // Validate model types const validModels = [ 'card', 'dashboard', 'table', 'dataset', 'segment', 'collection', 'database', 'action', 'indexed-entity', 'metric', ]; const invalidModels = models.filter(model => !validModels.includes(model)); if (invalidModels.length > 0) { logWarn(`Invalid model types specified: ${invalidModels.join(', ')}`, { requestId }); throw new McpError( ErrorCode.InvalidParams, `Invalid model types: ${invalidModels.join(', ')}. Valid types are: ${validModels.join(', ')}` ); } // Validate database_id if provided if (databaseId && (typeof databaseId !== 'number' || databaseId <= 0)) { logWarn('Invalid database_id parameter', { requestId }); throw new McpError(ErrorCode.InvalidParams, 'database_id must be a positive integer'); } // Validate search_native_query - only allowed when searching cards exclusively if (searchNativeQuery && (models.length !== 1 || models[0] !== 'card')) { logWarn('search_native_query parameter can only be used when searching cards exclusively', { requestId, }); throw new McpError( ErrorCode.InvalidParams, 'search_native_query parameter can only be used when models=["card"] - it searches within SQL query content of cards' ); } // Validate include_dashboard_questions - only allowed when dashboard is in models if (includeDashboardQuestions && !models.includes('dashboard')) { logWarn( 'include_dashboard_questions parameter can only be used when dashboard model is included', { requestId } ); throw new McpError( ErrorCode.InvalidParams, 'include_dashboard_questions parameter can only be used when "dashboard" is included in the models array' ); } logDebug(`Search with query: "${searchQuery}", models: ${models.join(', ')}`); const searchStartTime = Date.now(); try { // Build search parameters const searchParams = new URLSearchParams(); if (searchQuery) { searchParams.append('q', searchQuery); } // Add models models.forEach(model => searchParams.append('models', model)); // Add optional parameters if (searchNativeQuery) { searchParams.append('search_native_query', 'true'); } if (includeDashboardQuestions) { searchParams.append('include_dashboard_questions', 'true'); } if (ids && ids.length > 0) { ids.forEach(id => searchParams.append('ids', id.toString())); } if (archived === true) { searchParams.append('archived', 'true'); } if (databaseId) { searchParams.append('table_db_id', databaseId.toString()); } if (verified === true) { searchParams.append('verified', 'true'); } const response = await apiClient.request<any>(`/api/search?${searchParams.toString()}`); const searchTime = Date.now() - searchStartTime; // Extract results and limit let results = response.data || response || []; if (Array.isArray(results)) { results = results.slice(0, maxResults); } else { results = []; } // Enhance results with model-specific metadata (without individual recommendations) const enhancedResults = results.map((item: any) => { // Base item without created_at and updated_at return { id: item.id, name: item.name, description: item.description, model: item.model, collection_name: item.collection_name, database_id: item.database_id || null, table_id: item.table_id || null, archived: item.archived || false, }; }); // Group results by model type for better organization const resultsByModel = enhancedResults.reduce((acc: any, item: any) => { if (!acc[item.model]) { acc[item.model] = []; } acc[item.model].push(item); return acc; }, {}); const totalResults = enhancedResults.length; const searchMethod = ids && ids.length > 0 ? 'id_search' : databaseId && !searchQuery ? 'database_search' : 'query_search'; logInfo( `Search found ${totalResults} items across ${Object.keys(resultsByModel).length} model types in ${searchTime}ms` ); // Build standardized parameters object for response const usedParameters: any = { query: searchQuery || null, models: models, max_results: maxResults, }; // Add optional parameters that were actually used if (searchNativeQuery) { usedParameters.search_native_query = searchNativeQuery; } if (includeDashboardQuestions) { usedParameters.include_dashboard_questions = includeDashboardQuestions; } if (ids && ids.length > 0) { usedParameters.ids = ids; } if (archived === true) { usedParameters.archived = archived; } if (databaseId) { usedParameters.database_id = databaseId; } if (verified === true) { usedParameters.verified = verified; } // Generate recommended actions based on found models const foundModels = Object.keys(resultsByModel); const recommendedActions: { [key: string]: string } = {}; foundModels.forEach(model => { switch (model) { case 'card': recommendedActions[model] = 'Use retrieve(model="card", ids=[card_id]) to get the SQL query, then execute_query() with the database_id for reliable execution'; break; case 'dashboard': recommendedActions[model] = 'Use retrieve(model="dashboard", ids=[dashboard_id]) to get all cards in this dashboard and their details'; break; case 'table': recommendedActions[model] = 'Use retrieve(model="table", ids=[table_id]) to get detailed metadata including column information and relationships'; break; case 'database': recommendedActions[model] = 'Use retrieve(model="database", ids=[database_id]) to get database details including available tables'; break; case 'dataset': recommendedActions[model] = 'Use retrieve(model="card", ids=[dataset_id]) to get the dataset definition, then execute_query() to run it'; break; case 'collection': recommendedActions[model] = 'Use retrieve(model="collection", ids=[collection_id]) to get collection details and organizational structure for managing Metabase content like questions and dashboards'; break; case 'field': recommendedActions[model] = 'Use retrieve(model="field", ids=[field_id]) to get detailed field metadata including data types and constraints'; break; case 'segment': recommendedActions[model] = 'Use retrieve(model="card", ids=[segment_id]) to get the segment definition and apply it in your queries'; break; case 'metric': recommendedActions[model] = 'Use retrieve(model="card", ids=[metric_id]) to get the metric definition and incorporate it into your analysis'; break; default: recommendedActions[model] = 'Use the appropriate retrieve() command with the model type and ID to get detailed information'; } }); return { content: [ { type: 'text', text: formatJson({ search_metrics: { method: searchMethod, total_results: totalResults, search_time_ms: searchTime, parameters_used: usedParameters, }, recommended_actions: recommendedActions, results_by_model: Object.keys(resultsByModel).map(model => ({ model, count: resultsByModel[model].length, })), results: enhancedResults, }), }, ], }; } catch (error: any) { throw handleApiError( error, { operation: 'Search', resourceType: databaseId ? 'database' : undefined, resourceId: databaseId, }, logError ); } }

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/jerichosequitin/Metabase'

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