Skip to main content
Glama
resourceHandlers.ts21.4 kB
import { generateRequestId, formatJson } from '../../utils/index.js'; import { ErrorCode, McpError, isMcpError } from '../../types/core.js'; import { MetabaseApiClient } from '../../api.js'; import { optimizeDashboardResource, optimizeCardResource, optimizeDatabaseResource, optimizeTableResource, optimizeMetricResource, OptimizationLevel, } from './optimizers.js'; import { ListResourcesRequest, ReadResourceRequest, ListResourceTemplatesRequest, ResourceTemplate, Resource, ResourceContent, LogFunction, } from './types.js'; /** * Handle listing all available resources using hierarchical approach for better scalability */ export async function handleListResources( _request: ListResourcesRequest, apiClient: MetabaseApiClient, logInfo: LogFunction, logError: LogFunction ) { const requestId = generateRequestId(); logInfo('Processing request to list hierarchical Metabase resources', { requestId }); await apiClient.getSessionToken(); try { const resources: Resource[] = []; let totalResourceCount = 0; let currentUserId: number | null = null; // Get current user information for personal collection filtering try { const userResponse = await apiClient.getCurrentUser(); currentUserId = userResponse.data?.id || null; logInfo(`Current user ID: ${currentUserId}`, { requestId }); } catch (error) { logError('Failed to fetch current user info, will exclude all personal collections', error); } // Fetch core data: collections and databases const [collectionsResponse, databasesResponse] = await Promise.all([ apiClient.getCollectionsList().catch(error => { logError('Failed to fetch collections', error); return { data: [], source: 'error' }; }), apiClient.getDatabasesList().catch(error => { logError('Failed to fetch databases', error); return { data: [], source: 'error' }; }), ]); // Add ROOT collections only (location: "/") and user's own personal collection if (collectionsResponse.data.length > 0) { const rootCollectionResources = collectionsResponse.data .filter((collection: any) => { // Include non-personal collections (regular collections) if (!collection.personal_owner_id) { return collection.location === '/'; // Only root collections for non-personal } // Include user's own personal collection if we have their user ID if (currentUserId && collection.personal_owner_id === currentUserId) { return true; } // Exclude other users' personal collections return false; }) .map((collection: any) => ({ uri: `metabase://collection/${collection.id}`, mimeType: 'application/json', name: `[Collection] ${collection.name}`, description: `Collection: ${collection.name}${collection.description ? ` - ${collection.description}` : ''}`, isPersonal: !!collection.personal_owner_id, })); resources.push(...rootCollectionResources); totalResourceCount += rootCollectionResources.length; logInfo( `Added ${rootCollectionResources.length} collections (${collectionsResponse.data.length} total, filtered to root collections and user's personal collection)` ); } // Add ALL databases (important for navigation) if (databasesResponse.data.length > 0) { const databaseResources = databasesResponse.data .filter((database: any) => !database.is_sample) // Exclude sample databases .map((database: any) => ({ uri: `metabase://database/${database.id}`, mimeType: 'application/json', name: `[Database] ${database.name}`, description: `Database: ${database.name} (${database.engine})${database.description ? ` - ${database.description}` : ''}`, })); resources.push(...databaseResources); totalResourceCount += databaseResources.length; logInfo( `Added ${databaseResources.length} databases (${databasesResponse.data.length} total, source: ${databasesResponse.source})` ); } // Sort resources by type priority for better organization const sortedResources = resources.sort((a, b) => { const typeOrder = { '[Collection]': 0, // Collections first '[Database]': 1, // Databases second }; const getType = (name: string) => { for (const key of Object.keys(typeOrder)) { if (name.startsWith(key)) { return typeOrder[key as keyof typeof typeOrder]; } } return 999; // Unknown type goes to the end }; const aType = getType(a.name); const bType = getType(b.name); if (aType !== bType) { return aType - bType; } // Within collections, prioritize personal collections first if (aType === 0 && bType === 0) { // Both are collections const aIsPersonal = (a as any).isPersonal || false; const bIsPersonal = (b as any).isPersonal || false; if (aIsPersonal && !bIsPersonal) { return -1; // Personal collection comes first } if (!aIsPersonal && bIsPersonal) { return 1; // Personal collection comes first } } // Within same type, sort alphabetically by name return a.name.localeCompare(b.name); }); // Clean up sorting helper properties before returning const finalResources = sortedResources.map(resource => { const resourceCopy = { ...resource }; delete (resourceCopy as any).isPersonal; return resourceCopy; }); logInfo( `Successfully retrieved ${finalResources.length} view-based resources (from ${totalResourceCount} total items)` ); return { resources: finalResources }; } catch (error) { logError('Failed to retrieve Metabase resources', error); throw new McpError(ErrorCode.InternalError, 'Failed to retrieve Metabase resources'); } } /** * Handle listing resource templates */ export async function handleListResourceTemplates( _request: ListResourceTemplatesRequest, logInfo: LogFunction ) { logInfo('Processing request to list resource templates'); const resourceTemplates: ResourceTemplate[] = [ { uriTemplate: 'metabase://dashboard/{id}', name: 'Dashboard by ID', mimeType: 'application/json', description: 'Get a Metabase dashboard by its ID', }, { uriTemplate: 'metabase://card/{id}', name: 'Card by ID', mimeType: 'application/json', description: 'Get a Metabase question/card by its ID', }, { uriTemplate: 'metabase://database/{id}', name: 'Database by ID', mimeType: 'application/json', description: 'Get a Metabase database with its tables and metadata', }, { uriTemplate: 'metabase://table/{id}', name: 'Table by ID', mimeType: 'application/json', description: 'Get table schema, fields, and metadata by table ID', }, { uriTemplate: 'metabase://field/{id}', name: 'Field by ID', mimeType: 'application/json', description: 'Get detailed field information including type, constraints, and relationships', }, { uriTemplate: 'metabase://collection/{id}', name: 'Collection by ID', mimeType: 'application/json', description: 'Get collection details with all items (cards, dashboards, sub-collections)', }, { uriTemplate: 'metabase://metric/{id}', name: 'Metric by ID', mimeType: 'application/json', description: 'Get metric definition and calculation details by its ID', }, { uriTemplate: 'metabase://recent/{model}', name: 'Recent Items', mimeType: 'application/json', description: 'Get recently viewed items by model type (card, dashboard, table)', }, ]; return { resourceTemplates }; } /** * Handle reading a specific resource */ export async function handleReadResource( request: ReadResourceRequest, apiClient: MetabaseApiClient, logInfo: LogFunction, logWarn: LogFunction, logDebug: LogFunction, logError: LogFunction ) { const requestId = generateRequestId(); logInfo('Processing request to read resource', { requestId, uri: request.params?.uri, }); await apiClient.getSessionToken(); const uri = request.params?.uri; if (!uri) { logWarn('Missing URI parameter in resource request', { requestId }); throw new McpError(ErrorCode.InvalidParams, 'URI parameter is required'); } let match; try { // Handle dashboard resource if ((match = uri.match(/^metabase:\/\/dashboard\/(\d+)$/))) { return await handleDashboardResource(match[1], uri, apiClient, logDebug, logInfo); } // Handle card/question resource if ((match = uri.match(/^metabase:\/\/card\/(\d+)$/))) { return await handleCardResource(match[1], uri, apiClient, logDebug, logInfo); } // Handle database resource (includes tables) if ((match = uri.match(/^metabase:\/\/database\/(\d+)$/))) { return await handleDatabaseResource(match[1], uri, apiClient, logDebug, logInfo); } // Handle table resource (includes fields) if ((match = uri.match(/^metabase:\/\/table\/(\d+)$/))) { return await handleTableResource(match[1], uri, apiClient, logDebug, logInfo); } // Handle field resource if ((match = uri.match(/^metabase:\/\/field\/(\d+)$/))) { return await handleFieldResource(match[1], uri, apiClient, logDebug, logInfo); } // Handle collection resource (includes items) if ((match = uri.match(/^metabase:\/\/collection\/(\d+)$/))) { return await handleCollectionResource(match[1], uri, apiClient, logDebug, logInfo); } // Handle recent items resource if ((match = uri.match(/^metabase:\/\/recent\/(.+)$/))) { return await handleRecentItemsResource(match[1], uri, apiClient, logDebug, logInfo); } // Handle metric resource if ((match = uri.match(/^metabase:\/\/metric\/(\d+)$/))) { return await handleMetricResource(match[1], uri, apiClient, logDebug, logInfo); } logWarn(`Invalid URI format: ${uri}`, { requestId }); throw new McpError(ErrorCode.InvalidRequest, `Invalid URI format: ${uri}`); } catch (error) { if (isMcpError(error)) { throw error; } const apiError = error as any; const errorMessage = apiError.data?.message || apiError.message || 'Unknown error'; logError(`Failed to fetch Metabase resource: ${errorMessage}`, error); throw new McpError(ErrorCode.InternalError, `Metabase API error: ${errorMessage}`); } } /** * Handle dashboard resource */ async function handleDashboardResource( id: string, uri: string, apiClient: MetabaseApiClient, logDebug: LogFunction, logInfo: LogFunction ) { const dashboardId = parseInt(id, 10); logDebug(`Fetching dashboard with ID: ${dashboardId}`); const response = await apiClient.getDashboard(dashboardId); logInfo( `Successfully retrieved dashboard: ${response.data.name || dashboardId} (source: ${response.source})` ); // Optimize the dashboard response to reduce token usage const optimizedDashboard = optimizeDashboardResource(response.data, OptimizationLevel.STANDARD); const contents: ResourceContent[] = [ { uri, mimeType: 'application/json', text: formatJson(optimizedDashboard), }, ]; return { contents }; } /** * Handle card/question resource */ async function handleCardResource( id: string, uri: string, apiClient: MetabaseApiClient, logDebug: LogFunction, logInfo: LogFunction ) { const cardId = parseInt(id, 10); logDebug(`Fetching card/question with ID: ${cardId}`); const response = await apiClient.getCard(cardId); logInfo( `Successfully retrieved card: ${response.data.name || cardId} (source: ${response.source})` ); // Optimize the card response to reduce token usage const optimizedCard = optimizeCardResource(response.data, OptimizationLevel.STANDARD); const contents: ResourceContent[] = [ { uri, mimeType: 'application/json', text: formatJson(optimizedCard), }, ]; return { contents }; } /** * Handle database resource */ async function handleDatabaseResource( id: string, uri: string, apiClient: MetabaseApiClient, logDebug: LogFunction, logInfo: LogFunction ) { const databaseId = parseInt(id, 10); logDebug(`Fetching database with ID: ${databaseId}`); const response = await apiClient.getDatabase(databaseId); logInfo( `Successfully retrieved database: ${response.data.name || databaseId} (source: ${response.source})` ); // Optimize the database response to reduce token usage const optimizedDatabase = optimizeDatabaseResource(response.data, OptimizationLevel.STANDARD); const contents: ResourceContent[] = [ { uri, mimeType: 'application/json', text: formatJson(optimizedDatabase), }, ]; return { contents }; } /** * Handle table resource (by table ID only) */ async function handleTableResource( id: string, uri: string, apiClient: MetabaseApiClient, logDebug: LogFunction, logInfo: LogFunction ) { const tableId = parseInt(id, 10); logDebug(`Fetching table with ID: ${tableId}`); const tableResponse = await apiClient.getTable(tableId); logInfo( `Successfully retrieved table: ${tableResponse.data.name || tableId} (source: ${tableResponse.source})` ); // Optimize the table response to reduce token usage significantly const optimizedTable = optimizeTableResource(tableResponse.data, OptimizationLevel.STANDARD); const contents: ResourceContent[] = [ { uri, mimeType: 'application/json', text: formatJson(optimizedTable), }, ]; return { contents }; } /** * Handle field resource */ async function handleFieldResource( id: string, uri: string, apiClient: MetabaseApiClient, logDebug: LogFunction, logInfo: LogFunction ) { const fieldId = parseInt(id, 10); logDebug(`Fetching field with ID: ${fieldId}`); const fieldResponse = await apiClient.getField(fieldId); logInfo( `Successfully retrieved field: ${fieldResponse.data.name || fieldId} (source: ${fieldResponse.source})` ); // Create optimized field response const field = fieldResponse.data; const optimizedField = { id: field.id, name: field.name, display_name: field.display_name, description: field.description, base_type: field.base_type, semantic_type: field.semantic_type, field_type: field.field_type, position: field.position, visibility_type: field.visibility_type, nullable: field.nullable, auto_increment: field.auto_increment, pk: field.pk, unique: field.unique, table_id: field.table_id, database_id: field.database_id, fk_target_field_id: field.fk_target_field_id, target: field.target, fingerprint: field.fingerprint, has_field_values: field.has_field_values, dimensions: field.dimensions, values: field.values, settings: field.settings, caveats: field.caveats, points_of_interest: field.points_of_interest, created_at: field.created_at, updated_at: field.updated_at, retrieved_at: new Date().toISOString(), }; const contents: ResourceContent[] = [ { uri, mimeType: 'application/json', text: formatJson(optimizedField), }, ]; return { contents }; } /** * Handle metric resource */ async function handleMetricResource( id: string, uri: string, apiClient: MetabaseApiClient, logDebug: LogFunction, logInfo: LogFunction ) { const metricId = parseInt(id, 10); logDebug(`Fetching metric with ID: ${metricId}`); // Get metric from search results (metrics are returned in search) const searchParams = new URLSearchParams(); searchParams.append('models', 'metric'); searchParams.append('limit', '200'); const searchResponse = await apiClient.request<any>(`/api/search?${searchParams.toString()}`); const searchResults = searchResponse.data || searchResponse; const metric = searchResults.find((item: any) => item.id === metricId); if (!metric) { throw new McpError(ErrorCode.InvalidRequest, `Metric with ID ${metricId} not found`); } logInfo(`Successfully retrieved metric: ${metric.name}`); // Optimize the metric response to reduce token usage const optimizedMetric = optimizeMetricResource(metric, OptimizationLevel.STANDARD); const contents: ResourceContent[] = [ { uri, mimeType: 'application/json', text: formatJson(optimizedMetric), }, ]; return { contents }; } /** * Handle individual collection resource - returns collection items */ async function handleCollectionResource( id: string, uri: string, apiClient: MetabaseApiClient, logDebug: LogFunction, logInfo: LogFunction ) { const collectionId = parseInt(id, 10); logDebug(`Fetching collection items for collection ID: ${collectionId}`); // Get both collection metadata and its items const [collectionResponse, itemsResponse] = await Promise.all([ apiClient.getCollection(collectionId), apiClient.getCollectionItems(collectionId), ]); const collection = collectionResponse.data; const items = itemsResponse.data || []; logInfo(`Successfully retrieved collection "${collection.name}" with ${items.length} items`); // Organize items by type for better presentation const organizedItems = { cards: items.filter((item: any) => item.model === 'card'), dashboards: items.filter((item: any) => item.model === 'dashboard'), collections: items.filter((item: any) => item.model === 'collection'), other: items.filter((item: any) => !['card', 'dashboard', 'collection'].includes(item.model)), }; // Create response with collection metadata and organized items const collectionWithItems = { id: collection.id, name: collection.name, description: collection.description, location: collection.location, created_at: collection.created_at, updated_at: collection.updated_at, archived: collection.archived, items: { total_count: items.length, cards: organizedItems.cards.map((card: any) => ({ id: card.id, name: card.name, description: card.description, model: card.model, view_count: card.view_count, })), dashboards: organizedItems.dashboards.map((dashboard: any) => ({ id: dashboard.id, name: dashboard.name, description: dashboard.description, model: dashboard.model, view_count: dashboard.view_count, })), collections: organizedItems.collections.map((subcollection: any) => ({ id: subcollection.id, name: subcollection.name, description: subcollection.description, model: subcollection.model, })), other: organizedItems.other.map((item: any) => ({ id: item.id, name: item.name, description: item.description, model: item.model, })), }, retrieved_at: new Date().toISOString(), }; const contents: ResourceContent[] = [ { uri, mimeType: 'application/json', text: formatJson(collectionWithItems), }, ]; return { contents }; } /** * Handle recent items resource - get recently viewed items by model type */ async function handleRecentItemsResource( model: string, uri: string, apiClient: MetabaseApiClient, logDebug: LogFunction, logInfo: LogFunction ) { logDebug(`Fetching recent items for model: ${model}`); // Validate model type const validModels = ['card', 'dashboard', 'table']; if (!validModels.includes(model)) { throw new McpError( ErrorCode.InvalidRequest, `Invalid model type: ${model}. Valid models are: ${validModels.join(', ')}` ); } // Use search API to get recent items const searchParams = new URLSearchParams(); searchParams.append('models', model); searchParams.append('limit', '50'); const searchResponse = await apiClient.request<any>(`/api/search?${searchParams.toString()}`); const items = searchResponse.data || searchResponse; // Sort by last_viewed_at if available, otherwise by updated_at const sortedItems = items .filter((item: any) => !item.archived) .sort((a: any, b: any) => { const aDate = new Date(a.last_viewed_at || a.updated_at || 0).getTime(); const bDate = new Date(b.last_viewed_at || b.updated_at || 0).getTime(); return bDate - aDate; }) .slice(0, 20); // Top 20 most recent logInfo(`Successfully retrieved ${sortedItems.length} recent ${model} items`); const recentItems = { model, items: sortedItems.map((item: any) => ({ id: item.id, name: item.name, description: item.description, collection_id: item.collection_id, collection_name: item.collection?.name, last_viewed_at: item.last_viewed_at, view_count: item.view_count, created_at: item.created_at, updated_at: item.updated_at, })), item_count: sortedItems.length, retrieved_at: new Date().toISOString(), }; const contents: ResourceContent[] = [ { uri, mimeType: 'application/json', text: formatJson(recentItems), }, ]; return { contents }; }

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