import type z from 'zod'; import { Buffer } from 'node:buffer'; import process from 'node:process'; import { DEFAULT_DEPARTMENTS_CACHE_TTL_MS, DEFAULT_MET_API_TIMEOUT_MS, } from '../constants.js'; import { DepartmentsSchema, ObjectResponseSchema, SearchResponseSchema, } from '../types/types.js'; import { metMuseumRateLimiter } from '../utils/RateLimiter.js'; type DeepNullToUndefined = T extends null ? undefined : T extends readonly (infer U)[] ? Array> : T extends object ? { [K in keyof T]: DeepNullToUndefined } : T; function normalizeNulls(value: T): DeepNullToUndefined { if (value === null) { return undefined as DeepNullToUndefined; } if (Array.isArray(value)) { return value.map(item => normalizeNulls(item)) as DeepNullToUndefined; } if (typeof value === 'object' && value !== null) { return Object.fromEntries( Object.entries(value).map(([key, val]) => [key, normalizeNulls(val)]), ) as DeepNullToUndefined; } return value as DeepNullToUndefined; } function getMetApiTimeoutMs(): number { const rawTimeout = process.env.MET_API_TIMEOUT_MS; if (!rawTimeout) { return DEFAULT_MET_API_TIMEOUT_MS; } const parsedTimeout = Number.parseInt(rawTimeout, 10); if (!Number.isFinite(parsedTimeout) || parsedTimeout <= 0) { // Note: Silently use default timeout. Invalid configuration is a developer // error and logging would leak implementation details in stdio mode. return DEFAULT_MET_API_TIMEOUT_MS; } return parsedTimeout; } function getDepartmentsCacheTtlMs(): number { const rawCacheTtl = process.env.MET_DEPARTMENTS_CACHE_TTL_MS; if (!rawCacheTtl) { return DEFAULT_DEPARTMENTS_CACHE_TTL_MS; } const parsedCacheTtl = Number.parseInt(rawCacheTtl, 10); if (!Number.isFinite(parsedCacheTtl) || parsedCacheTtl < 0) { // Note: Silently use default TTL. Invalid configuration is a developer // error and logging would leak implementation details in stdio mode. return DEFAULT_DEPARTMENTS_CACHE_TTL_MS; } return parsedCacheTtl; } function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } function isMetApiDebugEnabled(): boolean { const rawDebug = process.env.MET_API_DEBUG; if (!rawDebug) { return false; } const normalizedDebug = rawDebug.trim().toLowerCase(); return normalizedDebug === '1' || normalizedDebug === 'true' || normalizedDebug === 'yes' || normalizedDebug === 'on'; } function logSchemaValidationFailure( endpointName: string, parseError: z.ZodError, context?: Record, ): void { if (!isMetApiDebugEnabled()) { return; } const payload = { endpoint: endpointName, context, issues: parseError.issues.map(issue => ({ path: issue.path.join('.'), code: issue.code, message: issue.message, })), }; process.stderr.write(`[metmuseum-mcp] schema validation failed: ${JSON.stringify(payload)}\n`); } function createUnexpectedResponseError(endpointName: string): MetMuseumApiError { return new MetMuseumApiError( `The Met Museum API returned an unexpected ${endpointName} response. Please try again later.`, undefined, true, ); } export class MetMuseumApiError extends Error { public readonly status: number | undefined; public readonly isUserFriendly: boolean; constructor(message: string, status?: number, isUserFriendly: boolean = false) { super(message); this.name = 'MetMuseumApiError'; this.status = status; this.isUserFriendly = isUserFriendly; } } export class MetMuseumApiClient { private readonly departmentsUrl: string = 'https://collectionapi.metmuseum.org/public/collection/v1/departments'; private readonly searchUrl: string = 'https://collectionapi.metmuseum.org/public/collection/v1/search'; private readonly objectBaseUrl: string = 'https://collectionapi.metmuseum.org/public/collection/v1/objects/'; private readonly requestTimeoutMs: number = getMetApiTimeoutMs(); private readonly departmentsCacheTtlMs: number = getDepartmentsCacheTtlMs(); private readonly transientRetryCount: number = 1; private readonly transientRetryBackoffMs: number = 250; private departmentsCacheExpiresAt: number = 0; private departmentsCache?: z.infer['departments']; private departmentsRequestInFlight?: Promise['departments']>; public async listDepartments(): Promise['departments']> { const now = Date.now(); if (this.departmentsCache && now < this.departmentsCacheExpiresAt) { return this.departmentsCache; } if (!this.departmentsRequestInFlight) { this.departmentsRequestInFlight = this.fetchAndParse(this.departmentsUrl, DepartmentsSchema, 'departments') .then((data) => { this.departmentsCache = data.departments; this.departmentsCacheExpiresAt = Date.now() + this.departmentsCacheTtlMs; return data.departments; }) .finally(() => { this.departmentsRequestInFlight = undefined; }); } return await this.departmentsRequestInFlight; } public async searchObjects({ q, hasImages, title, departmentId, isHighlight, tags, isOnView, artistOrCulture, medium, geoLocation, dateBegin, dateEnd, }: { q: string; hasImages?: boolean; title?: boolean; departmentId?: number; isHighlight?: boolean; tags?: boolean; isOnView?: boolean; artistOrCulture?: boolean; medium?: string; geoLocation?: string; dateBegin?: number; dateEnd?: number; }): Promise> { if ((dateBegin === undefined) !== (dateEnd === undefined)) { throw new MetMuseumApiError( 'Both dateBegin and dateEnd are required when filtering by date range.', undefined, true, ); } const url = new URL(this.searchUrl); url.searchParams.set('q', q); if (hasImages) { url.searchParams.set('hasImages', 'true'); } if (title) { url.searchParams.set('title', 'true'); } if (typeof departmentId === 'number') { url.searchParams.set('departmentId', departmentId.toString()); } if (isHighlight) { url.searchParams.set('isHighlight', 'true'); } if (tags) { url.searchParams.set('tags', 'true'); } if (isOnView) { url.searchParams.set('isOnView', 'true'); } if (artistOrCulture) { url.searchParams.set('artistOrCulture', 'true'); } if (medium) { url.searchParams.set('medium', medium); } if (geoLocation) { url.searchParams.set('geoLocation', geoLocation); } if (typeof dateBegin === 'number') { url.searchParams.set('dateBegin', dateBegin.toString()); } if (typeof dateEnd === 'number') { url.searchParams.set('dateEnd', dateEnd.toString()); } return await this.fetchAndParse(url.toString(), SearchResponseSchema, 'search'); } public async getObject(objectId: number): Promise> { const url = `${this.objectBaseUrl}${objectId}`; const rawData = await this.fetchJson(url); const normalizedData = normalizeNulls(rawData); const parseResult = ObjectResponseSchema.safeParse(normalizedData); if (!parseResult.success) { logSchemaValidationFailure('object', parseResult.error, { objectId, url }); throw createUnexpectedResponseError('object'); } return parseResult.data; } public async getImageAsBase64(imageUrl: string): Promise<{ data: string; mimeType: string }> { let response: Response; try { response = await this.fetchWithTransientRetry(imageUrl); } catch (error) { if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) { throw new MetMuseumApiError( 'The artwork image is taking too long to load. Please try again.', undefined, true, ); } if (error instanceof TypeError && error.message.includes('fetch')) { throw new MetMuseumApiError( 'The artwork image is unreachable right now. Please try again.', undefined, true, ); } throw error; } if (!response.ok) { let userMessage = 'Unable to load the artwork image right now.'; if (response.status === 404) { userMessage = 'The artwork image was not found.'; } else if (response.status === 429) { userMessage = 'Too many requests while loading the artwork image. Please wait a moment and try again.'; } else if (response.status >= 500) { userMessage = 'The image host is experiencing issues. Please try again later.'; } throw new MetMuseumApiError(userMessage, response.status, true); } const mimeTypeHeader = response.headers.get('content-type') ?? ''; const parsedMimeType = mimeTypeHeader.split(';')[0]?.trim(); const mimeType = parsedMimeType?.startsWith('image/') ? parsedMimeType : 'image/jpeg'; const imageBytes = await response.arrayBuffer(); return { data: Buffer.from(imageBytes).toString('base64'), mimeType, }; } private async fetchAndParse( url: string, schema: z.ZodType, endpointName: string, ): Promise { const data = await this.fetchJson(url); const parseResult = schema.safeParse(data); if (!parseResult.success) { logSchemaValidationFailure(endpointName, parseResult.error, { url }); throw createUnexpectedResponseError(endpointName); } return parseResult.data; } private async fetchJson(url: string): Promise { let response: Response; try { response = await this.fetchWithTransientRetry(url); } catch (error) { if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) { throw new MetMuseumApiError( `The Met Museum API is taking too long to respond. Please try again.`, undefined, true, // isUserFriendly ); } // Network errors (DNS, connection refused, etc.) if (error instanceof TypeError && error.message.includes('fetch')) { throw new MetMuseumApiError( `The Met Museum API is unreachable. Please check your internet connection and try again.`, undefined, true, // isUserFriendly ); } throw error; } if (!response.ok) { let userMessage = `The Met Museum API returned an error`; if (response.status === 404) { userMessage = 'The requested object or department was not found.'; } else if (response.status === 429) { userMessage = 'Too many requests to the Met Museum API. Please wait a moment and try again.'; } else if (response.status >= 500) { userMessage = 'The Met Museum API is experiencing issues. Please try again later.'; } else if (response.status >= 400) { userMessage = `The Met Museum API rejected the request (HTTP ${response.status}).`; } throw new MetMuseumApiError(userMessage, response.status, true); } return await response.json(); } private async fetchWithTransientRetry(url: string): Promise { let attempt = 0; while (true) { try { const response = await metMuseumRateLimiter.fetch(url, { signal: AbortSignal.timeout(this.requestTimeoutMs), }); if (response.status === 503 && attempt < this.transientRetryCount) { await sleep(this.transientRetryBackoffMs * (attempt + 1)); attempt += 1; continue; } return response; } catch (error) { const isTimeout = error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError'); if (isTimeout && attempt < this.transientRetryCount) { await sleep(this.transientRetryBackoffMs * (attempt + 1)); attempt += 1; continue; } throw error; } } } }