Skip to main content
Glama
media.ts12 kB
import { readFile } from 'node:fs/promises'; import { basename, extname, isAbsolute, resolve as resolvePath } from 'node:path'; import { z } from 'zod'; import type { ToolRegistrationContext } from '../context.js'; import { normalizeDocument, normalizePhoto } from '../../utils/formatters.js'; const mediaInputShape = { mediaType: z.enum(['document', 'photo']), action: z.enum(['list', 'get', 'upload', 'delete']), mediaId: z.number().int().positive().optional(), contactId: z.number().int().positive().optional(), limit: z.number().int().min(1).max(100).optional(), page: z.number().int().min(1).optional(), filePath: z.string().min(1).optional(), base64Data: z.string().min(1).optional(), fileName: z.string().min(1).optional(), mimeType: z.string().min(1).optional() } as const; const mediaInputSchema = z .object(mediaInputShape) .superRefine((input, ctx) => { if (['get', 'delete'].includes(input.action) && input.mediaId == null) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'mediaId is required for get and delete actions.', path: ['mediaId'] }); } if (input.action === 'upload') { if (!input.filePath && !input.base64Data) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Provide either filePath or base64Data when uploading media.', path: ['filePath'] }); } if (input.filePath && input.base64Data) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Choose either filePath or base64Data, not both.', path: ['filePath'] }); } if (!input.filePath && !input.fileName) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'fileName is required when providing base64Data directly.', path: ['fileName'] }); } } }); type MediaInput = z.infer<typeof mediaInputSchema>; type DocumentInput = MediaInput & { mediaType: 'document' }; type PhotoInput = MediaInput & { mediaType: 'photo' }; type HandlerArgs<T extends MediaInput> = { input: T; client: ToolRegistrationContext['client']; logger: ToolRegistrationContext['logger']; }; export function registerMediaTools(context: ToolRegistrationContext): void { const { server, client, logger } = context; server.registerTool( 'monica_manage_media', { title: 'Manage Monica documents and photos', description: 'List, inspect, upload, or delete documents and photos stored in Monica. Use mediaType to pick the resource.', inputSchema: mediaInputShape }, async (rawInput: unknown) => { const input = mediaInputSchema.parse(rawInput); if (input.mediaType === 'document') { return handleDocument({ input: input as DocumentInput, client, logger }); } return handlePhoto({ input: input as PhotoInput, client, logger }); } ); } async function handleDocument({ input, client, logger }: HandlerArgs<DocumentInput>) { switch (input.action) { case 'list': { const response = await client.listDocuments({ contactId: input.contactId, limit: input.limit, page: input.page }); const documents = response.data.map(normalizeDocument); return buildListResponse({ mediaType: input.mediaType, action: input.action, contactId: input.contactId, records: documents, pagination: response.meta }); } case 'get': { if (input.mediaId == null) { return missingMediaIdError('document', input.action); } const response = await client.getDocument(input.mediaId); const document = normalizeDocument(response.data); return buildSingleRecordResponse({ mediaType: input.mediaType, action: input.action, mediaId: input.mediaId, record: document, text: `Document ${document.originalFilename} (ID ${document.id}).` }); } case 'upload': { const upload = await prepareUploadPayload(input); const response = await client.uploadDocument({ base64Data: upload.base64Data, fileName: upload.fileName, mimeType: upload.mimeType, contactId: input.contactId }); const document = normalizeDocument(response.data); logger.info({ documentId: document.id, contactId: input.contactId }, 'Uploaded Monica document'); return buildSingleRecordResponse({ mediaType: input.mediaType, action: input.action, record: document, text: `Uploaded document ${document.originalFilename} (ID ${document.id}).` }); } case 'delete': { if (input.mediaId == null) { return missingMediaIdError('document', input.action); } await client.deleteDocument(input.mediaId); logger.info({ documentId: input.mediaId }, 'Deleted Monica document'); return { content: [ { type: 'text' as const, text: `Deleted document ID ${input.mediaId}.` } ], structuredContent: { mediaType: input.mediaType, action: input.action, mediaId: input.mediaId, deleted: true } }; } default: return unknownActionError(input.action); } } async function handlePhoto({ input, client, logger }: HandlerArgs<PhotoInput>) { switch (input.action) { case 'list': { const response = await client.listPhotos({ contactId: input.contactId, limit: input.limit, page: input.page }); const photos = response.data.map(normalizePhoto); return buildListResponse({ mediaType: input.mediaType, action: input.action, contactId: input.contactId, records: photos, pagination: response.meta }); } case 'get': { if (input.mediaId == null) { return missingMediaIdError('photo', input.action); } const response = await client.getPhoto(input.mediaId); const photo = normalizePhoto(response.data); return buildSingleRecordResponse({ mediaType: input.mediaType, action: input.action, mediaId: input.mediaId, record: photo, text: `Photo ${photo.originalFilename} (ID ${photo.id}).` }); } case 'upload': { const upload = await prepareUploadPayload(input); const response = await client.uploadPhoto({ base64Data: upload.base64Data, fileName: upload.fileName, mimeType: upload.mimeType, contactId: input.contactId }); const photo = normalizePhoto(response.data); logger.info({ photoId: photo.id, contactId: input.contactId }, 'Uploaded Monica photo'); return buildSingleRecordResponse({ mediaType: input.mediaType, action: input.action, record: photo, text: `Uploaded photo ${photo.originalFilename} (ID ${photo.id}).` }); } case 'delete': { if (input.mediaId == null) { return missingMediaIdError('photo', input.action); } await client.deletePhoto(input.mediaId); logger.info({ photoId: input.mediaId }, 'Deleted Monica photo'); return { content: [ { type: 'text' as const, text: `Deleted photo ID ${input.mediaId}.` } ], structuredContent: { mediaType: input.mediaType, action: input.action, mediaId: input.mediaId, deleted: true } }; } default: return unknownActionError(input.action); } } function missingMediaIdError(mediaType: 'document' | 'photo', action: string) { return { content: [ { type: 'text' as const, text: `Provide a ${mediaType} mediaId when using the ${action} action.` } ] }; } function unknownActionError(action: string) { return { content: [ { type: 'text' as const, text: `Unsupported action: ${action}` } ] }; } async function prepareUploadPayload(input: MediaInput) { if (!input.filePath && !input.base64Data) { throw new Error('Provide either filePath or base64Data for uploads.'); } if (input.filePath) { const resolvedPath = resolveFilePath(input.filePath); try { const fileBuffer = await readFile(resolvedPath); const fileName = input.fileName ? basename(input.fileName) : basename(resolvedPath); const mimeType = resolveMimeType(fileName, input.mimeType); return { base64Data: fileBuffer.toString('base64'), fileName, mimeType }; } catch (error) { throw new Error(`Unable to read file at ${resolvedPath}: ${(error as Error).message}`); } } if (!input.base64Data || !input.fileName) { throw new Error('Provide base64Data and fileName when not using filePath.'); } const fileName = basename(input.fileName); return { base64Data: input.base64Data, fileName, mimeType: resolveMimeType(fileName, input.mimeType) }; } function resolveFilePath(filePath: string): string { if (isAbsolute(filePath)) { return filePath; } return resolvePath(process.cwd(), filePath); } function resolveMimeType(fileName: string, provided?: string): string { if (provided) { return provided; } const extension = extname(fileName).toLowerCase(); return MIME_LOOKUP[extension] ?? 'application/octet-stream'; } function buildListResponse({ mediaType, action, contactId, records, pagination }: { mediaType: 'document' | 'photo'; action: string; contactId?: number; records: Array<ReturnType<typeof normalizeDocument> | ReturnType<typeof normalizePhoto>>; pagination: { current_page: number; last_page: number; per_page: number; total: number; }; }) { const scope = contactId ? `contact ${contactId}` : 'your account'; const summary = records.length ? `Found ${records.length} ${mediaType}${records.length === 1 ? '' : 's'} for ${scope}.` : `No ${mediaType}s found for ${scope}.`; const key = mediaType === 'document' ? 'documents' : 'photos'; return { content: [ { type: 'text' as const, text: summary } ], structuredContent: { mediaType, action, contactId, [key]: records, pagination: { currentPage: pagination.current_page, lastPage: pagination.last_page, perPage: pagination.per_page, total: pagination.total } } }; } function buildSingleRecordResponse({ mediaType, action, mediaId, record, text }: { mediaType: 'document' | 'photo'; action: string; mediaId?: number; record: ReturnType<typeof normalizeDocument> | ReturnType<typeof normalizePhoto>; text: string; }) { const key = mediaType === 'document' ? 'document' : 'photo'; return { content: [ { type: 'text' as const, text } ], structuredContent: { mediaType, action, mediaId: mediaId ?? record.id, [key]: record } }; } const MIME_LOOKUP: Record<string, string> = { '.aac': 'audio/aac', '.csv': 'text/csv', '.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.gif': 'image/gif', '.heic': 'image/heic', '.jpeg': 'image/jpeg', '.jpg': 'image/jpeg', '.json': 'application/json', '.mp3': 'audio/mpeg', '.mp4': 'video/mp4', '.pdf': 'application/pdf', '.png': 'image/png', '.ppt': 'application/vnd.ms-powerpoint', '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', '.svg': 'image/svg+xml', '.txt': 'text/plain', '.wav': 'audio/wav', '.webp': 'image/webp', '.xls': 'application/vnd.ms-excel', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.zip': 'application/zip' };

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/Jacob-Stokes/monica-mcp'

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