Skip to main content
Glama
handlers.ts18.9 kB
/* eslint-disable @typescript-eslint/no-explicit-any */ import { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js'; import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { v4 as uuidv4 } from 'uuid'; import { z } from 'zod'; import { server, todoistApi } from '../clients.js'; import { log } from './helpers.js'; import { SyncCommand } from './types.js'; type HandlerArgs<T extends z.ZodRawShape> = z.objectOutputType<T, z.ZodTypeAny>; /** * Validates path parameters to prevent path traversal attacks * @param value - The parameter value to validate * @param paramName - The name of the parameter (for error messages) * @returns The URL-encoded safe value * @throws Error if validation fails */ function validatePathParameter(value: string, paramName: string): string { const stringValue = String(value); // Check for path traversal characters if (/[/\\]|\.\./.test(stringValue)) { throw new Error( `Invalid characters in path parameter '${paramName}': Path traversal characters are not allowed` ); } // Ensure only safe alphanumeric characters, underscores, and hyphens if (!/^[a-zA-Z0-9_-]+$/.test(stringValue)) { throw new Error( `Invalid path parameter '${paramName}': Only alphanumeric characters, underscores, and hyphens are allowed` ); } return encodeURIComponent(stringValue); } export function createHandler<T extends z.ZodRawShape>( name: string, description: string, paramsSchema: T, handler: (args: HandlerArgs<T>) => Promise<any> ): void { const mcpToolCallback = async (args: HandlerArgs<T>): Promise<CallToolResult> => { try { const result = await handler(args); return { content: [ { type: 'text', text: JSON.stringify(result ?? null, null, 2).trim(), }, ], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`Error in tool ${name}:`, error); return { isError: true, content: [ { type: 'text', text: `Error executing tool '${name}': ${errorMessage}`, }, ], }; } }; // Crazy cast, if you can do it better, please, let me knows server.tool(name, description, paramsSchema, mcpToolCallback as unknown as ToolCallback<T>); } type HttpMethod = 'GET' | 'POST' | 'DELETE'; type ParamTransformer<T> = (args: T) => Record<string, any>; type ResultProcessor<T, R> = (result: any, args: T) => Promise<R> | R; export function createApiHandler<T extends z.ZodRawShape, R = any>(options: { name: string; description: string; schemaShape: T; method: HttpMethod; path: string; transformParams?: ParamTransformer<z.infer<z.ZodObject<T>>>; processResult?: ResultProcessor<z.infer<z.ZodObject<T>>, R>; }) { const handler = async (args: z.infer<z.ZodObject<T>>): Promise<R> => { let finalPath = options.path; const pathParams: Record<string, string> = {}; // Extract path parameters (e.g., {id}) and replace them with actual values const pathParamRegex = /{([^}]+)}/g; let match; while ((match = pathParamRegex.exec(options.path)) !== null) { const fullMatch = match[0]; // e.g., "{id}" const paramName = match[1]; // e.g., "id" if (args[paramName] === undefined) { throw new Error(`Path parameter ${paramName} is required but not provided`); } // Validate and encode path parameter using the centralized security function const safeParamValue = validatePathParameter(args[paramName], paramName); finalPath = finalPath.replace(fullMatch, safeParamValue); pathParams[paramName] = String(args[paramName]); } // Collect non-path parameters for query string or request body const otherParams = Object.entries(args).reduce( (acc, [key, value]) => { if (value !== undefined && !pathParams[key]) { acc[key] = value; } return acc; }, {} as Record<string, any> ); // Apply custom parameter transformation if provided const finalParams = options.transformParams ? options.transformParams(args) : otherParams; // Execute the API request based on HTTP method let result; switch (options.method) { case 'GET': result = await todoistApi.get(finalPath, finalParams); break; case 'POST': log('POST', finalPath, finalParams); result = await todoistApi.post(finalPath, finalParams); break; case 'DELETE': result = await todoistApi.delete(finalPath); break; } // Apply result post-processing if provided return options.processResult ? options.processResult(result, args) : result; }; return createHandler(options.name, options.description, options.schemaShape, handler); } export function createBatchApiHandler<T extends z.ZodRawShape>( options: { name: string; description: string; itemSchema: T; method: HttpMethod; mode?: 'read' | 'create' | 'update' | 'delete'; idField?: string; nameField?: string; findByName?: (name: string, items: any[]) => any | undefined; validateItem?: (item: any) => { valid: boolean; error?: string }; } & ( // Or we specify full path | { path: string; basePath?: never; pathSuffix?: never; } // Or we specify base path and path suffix | { path?: never; basePath: string; pathSuffix: string; } ) ) { // Create basic description, we cant properly use 'anyOf' here so for now we will add info to description let finalDescription = options.description; const requiresIdOrName = options.idField && options.nameField && options.mode !== 'create'; if (requiresIdOrName) { const requirementText = `\nEither '${options.idField}' or the '${options.nameField}' to identify the target.`; finalDescription += requirementText; } const itemSchemaObject = z.object(options.itemSchema); const enhancedItemSchema: z.ZodTypeAny = requiresIdOrName ? itemSchemaObject.refine( (data: any) => data[options.idField!] !== undefined || data[options.nameField!] !== undefined, { message: `Either ${options.idField} or ${options.nameField} must be provided`, path: [options.idField!, options.nameField!], } ) : itemSchemaObject; const batchSchema = z.object({ items: z.array(enhancedItemSchema), }); const handler = async (args: z.infer<typeof batchSchema>): Promise<any> => { const { items } = args; // For modes other than create, check if name lookup is needed let allItems: any[] = []; const needsNameLookup = options.mode !== 'create' && options.nameField && options.findByName && items.some(item => item[options.nameField!] && !item[options.idField!]); if (needsNameLookup) { // Determine the base path for fetching all items // Example: /tasks from /tasks/{id} const lookupPath = options.basePath || (options.path ? options.path.split('/{')[0] : ''); allItems = await todoistApi.get(lookupPath, {}); } const results = await Promise.all( items.map(async item => { if (options.validateItem) { const validation = options.validateItem(item); if (!validation.valid) { return { success: false, error: validation.error || 'Validation failed', item, }; } } try { let finalPath = ''; const apiParams = { ...item }; // For modes where need id if (options.mode !== 'create' && options.idField) { let itemId = item[options.idField]; let matchedName = null; let matchedContent = null; // If no ID but name is provided, search by name if (!itemId && item[options.nameField!] && options.findByName) { const searchName = item[options.nameField!]; const matchedItem = options.findByName(searchName, allItems); if (!matchedItem) { return { success: false, error: `Item not found with name: ${searchName}`, item, }; } itemId = matchedItem.id; matchedName = searchName; matchedContent = matchedItem.content; } if (!itemId) { return { success: false, error: `Either ${options.idField} or ${options.nameField} must be provided`, item, }; } // Apply security validation to itemId before using in path const safeItemId = validatePathParameter(itemId, options.idField || 'id'); if (options.basePath && options.pathSuffix) { finalPath = `${options.basePath}${options.pathSuffix.replace('{id}', safeItemId)}`; } else if (options.path) { finalPath = options.path.replace('{id}', safeItemId); } delete apiParams[options.idField]; if (options.nameField) { delete apiParams[options.nameField]; } let result; switch (options.method) { case 'GET': result = await todoistApi.get(finalPath, apiParams); break; case 'POST': result = await todoistApi.post(finalPath, apiParams); break; case 'DELETE': result = await todoistApi.delete(finalPath); break; } const response: any = { success: true, id: itemId, result, }; if (matchedName) { response.found_by_name = matchedName; response.matched_content = matchedContent; } return response; } // Create mode else { finalPath = options.path || options.basePath || ''; let result; switch (options.method) { case 'GET': result = await todoistApi.get(finalPath, apiParams); break; case 'POST': result = await todoistApi.post(finalPath, apiParams); break; case 'DELETE': result = await todoistApi.delete(finalPath); break; } return { success: true, created_item: result, }; } } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error), item, }; } }) ); const successCount = results.filter(r => r.success).length; return { success: successCount === items.length, summary: { total: items.length, succeeded: successCount, failed: items.length - successCount, }, results, }; }; return createHandler(options.name, finalDescription, batchSchema.shape, handler); } export function createSyncApiHandler<T extends z.ZodRawShape>(options: { name: string; description: string; itemSchema: T; commandType: string; // The sync command type (e.g., 'item_move') idField: string; nameField?: string; lookupPath?: string; // Configurable path for lookup findByName?: (name: string, items: any[]) => any | undefined; buildCommandArgs: (item: any, itemId: string) => Record<string, any>; validateItem?: (item: any) => { valid: boolean; error?: string }; // Optional validation }) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const batchSchema = z.object({ items: z.array(z.object(options.itemSchema)), }); const handler = async (args: z.infer<typeof batchSchema>): Promise<any> => { const { items } = args; try { // For name lookup if needed let allItems: any[] = []; const needsNameLookup = options.nameField && options.findByName && items.some(item => item[options.nameField!] && !item[options.idField]); if (needsNameLookup) { if (!options.lookupPath) { throw new Error(`Error: lookupPath must be specified for name-based lookup`); } // Generic lookup path for any resource type allItems = await todoistApi.get(options.lookupPath, {}); } // Build commands array for Sync API const commands: SyncCommand[] = []; const failedItems = []; for (const item of items) { // Optional pre-validation if (options.validateItem) { const validation = options.validateItem(item); if (!validation.valid) { failedItems.push({ success: false, error: validation.error || 'Validation failed', item, }); continue; } } let itemId = item[options.idField]; // Lookup by name if needed if (!itemId && item[options.nameField!] && options.findByName) { const searchName = item[options.nameField!]; const matchedItem = options.findByName(searchName, allItems); if (!matchedItem) { failedItems.push({ success: false, error: `Item not found with name: ${searchName}`, item, }); continue; } itemId = matchedItem.id; } if (!itemId) { failedItems.push({ success: false, error: `Either ${options.idField} or ${options.nameField} must be provided`, item, }); continue; } // Apply security validation to itemId before using in sync commands const safeItemId = validatePathParameter(itemId, options.idField); // Use the provided function to build command args with validated ID const commandArgs = options.buildCommandArgs(item, safeItemId); commands.push({ type: options.commandType, uuid: uuidv4(), args: commandArgs, }); } // If all items failed validation, return early if (failedItems.length === items.length) { return { success: false, summary: { total: items.length, succeeded: 0, failed: items.length, }, results: failedItems, }; } // Execute the sync command if any valid commands exist let syncResult = null; if (commands.length > 0) { syncResult = await todoistApi.sync(commands); } // Combine successful and failed results const successfulResults = commands.map(command => ({ success: true, id: command.args.id, command, })); const results = [...successfulResults, ...failedItems]; return { success: failedItems.length === 0, summary: { total: items.length, succeeded: items.length - failedItems.length, failed: failedItems.length, }, results, sync_result: syncResult, }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error), }; } }; return createHandler( options.name, options.description, { items: z.array(z.object(options.itemSchema)) }, handler ); }

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/stanislavlysenko0912/todoist-mcp-server'

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