Skip to main content
Glama

Reddit MCP Server

by ozipi
tool-handlers.ts21 kB
/** * @file MCP Tool request handlers * @module handlers/tool-handlers * * @remarks * This module implements the MCP tool handling functionality, managing * both tool listing and tool invocation. It serves as the main entry point * for all tool-related operations in the Reddit MCP server. * * @see {@link https://modelcontextprotocol.io/specification/2025-06-18/core/tools | MCP Tools Specification} */ import type { CallToolRequest, CallToolResult, ListToolsRequest, ListToolsResult, } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import { TOOLS, TOOL_ERROR_MESSAGES } from '../constants/tools.js'; import { BrainloopService } from '../services/brainloop/brainloop-service.js'; import { logger } from '../utils/logger.js'; import type { RedditAuthInfo, MCPToolContext } from '../types/request-context.js'; import { handleCreateBrainloop, handleViewBrainloops, handleGetBrainloop, handleExpandBrainloop, handleBrainloopProgress, handleGetUnitLessons, handleGetLesson, handleCreateInteraction, handleCreatePrompt, handleCreatePromptsBatch, handleGetLessonPrompts, handleUpdateLesson, handleUpdateUnit, handleDetectDuplicates, handleCleanupEmptyContent, handleReorderUnits, } from './tools/brainloop-handlers.js'; import { handleCreateTrack, handleViewTracks, handleGetTrack, handleAddCourseToTrack, handleEnrollInTrack, } from './tools/track-handlers.js'; /** * Zod schemas for brainloop tool validation */ const ToolSchemas = { create_brainloop: z.object({ title: z.string().min(1).describe("Title of the brainloop"), description: z.string().min(1).describe("Description of the brainloop"), topics: z.array(z.string()).min(1).describe("List of topics/units for the brainloop"), isPublic: z.boolean().optional().describe("Make brainloop public"), isPublished: z.boolean().optional().describe("Publish brainloop immediately"), }), view_brainloops: z.object({}), get_brainloop: z.object({ brainloopId: z.string().min(1).describe("The ID of the brainloop"), }), expand_brainloop: z.object({ brainloopId: z.string().min(1).describe("The ID of the brainloop to expand"), units: z.array(z.object({ title: z.string().min(1).describe("Unit title"), description: z.string().min(1).describe("Unit description"), lessons: z.array(z.object({ title: z.string().min(1).describe("Lesson title"), content: z.string().min(1).describe("Lesson content"), })).min(1).describe("Lessons for this unit"), })).min(1).describe("Units to add"), }), brainloop_progress: z.object({ brainloopId: z.string().optional().describe("Specific brainloop ID (optional)"), }), create_interaction: z.object({ lessonId: z.string().min(1).describe("The ID of the lesson"), type: z.string().optional().describe("Interaction type (assessment, exercise, reflection)"), }), create_prompt: z.object({ interactionId: z.string().min(1).describe("The ID of the interaction"), question: z.string().min(1).describe("The question text"), type: z.string().min(1).describe("Prompt type (multiple-choice, single-choice, short-answer, etc.)"), options: z.array(z.string()).optional().describe("Options for choice questions"), answer: z.any().optional().describe("The correct answer"), explanation: z.string().optional().describe("Explanation of the answer"), codeLanguage: z.string().optional().describe("Programming language for code questions"), codeStarterCode: z.string().optional().describe("Starter code for code questions"), codeExpectedOutput: z.string().optional().describe("Expected output for code questions"), codeTestCases: z.any().optional().describe("Test cases for code questions"), codeTimeLimit: z.number().optional().describe("Time limit for code execution"), codeMemoryLimit: z.number().optional().describe("Memory limit for code execution"), componentType: z.string().optional().describe("Interactive component type"), componentConfig: z.any().optional().describe("Component configuration"), componentAnswer: z.any().optional().describe("Expected component answer"), }), create_prompts_batch: z.object({ interactionId: z.string().min(1).describe("The ID of the interaction"), prompts: z.array(z.object({ question: z.string().min(1).describe("The question text"), type: z.string().min(1).describe("Prompt type"), options: z.array(z.string()).optional().describe("Options for choice questions"), answer: z.any().optional().describe("The correct answer"), explanation: z.string().optional().describe("Explanation of the answer"), codeLanguage: z.string().optional().describe("Programming language for code questions"), codeStarterCode: z.string().optional().describe("Starter code for code questions"), componentType: z.string().optional().describe("Interactive component type"), componentConfig: z.any().optional().describe("Component configuration"), })).min(1).describe("Array of prompts to create"), }), get_lesson_prompts: z.object({ lessonId: z.string().min(1).describe("The ID of the lesson"), }), update_lesson: z.object({ lessonId: z.string().min(1).describe("The ID of the lesson to update"), title: z.string().optional().describe("New lesson title"), content: z.string().optional().describe("New lesson content"), videoUrl: z.string().optional().describe("New video URL"), }), update_unit: z.object({ unitId: z.string().min(1).describe("The ID of the unit to update"), title: z.string().optional().describe("New unit title"), description: z.string().optional().describe("New unit description"), }), get_unit_lessons: z.object({ unitId: z.string().min(1).describe("The ID of the unit"), }), get_lesson: z.object({ lessonId: z.string().min(1).describe("The ID of the lesson to retrieve full content"), }), detect_duplicates: z.object({ courseId: z.string().min(1).describe("The ID of the course to check for duplicates"), }), cleanup_empty_content: z.object({ courseId: z.string().min(1).describe("The ID of the course to clean up"), dryRun: z.boolean().optional().describe("Preview mode - show what would be deleted without actually deleting"), }), reorder_units: z.object({ brainloopId: z.string().min(1).describe("The course ID containing the units to reorder"), unitIds: z.array(z.string()).min(1).describe("Array of unit IDs in desired order"), }), // BrainTrack tool schemas create_track: z.object({ title: z.string().min(1).describe("Title of the track"), description: z.string().min(1).describe("Description of the learning path"), icon: z.string().optional().describe("Emoji icon for the track"), hero: z.string().optional().describe("Hero image URL"), isPrivate: z.boolean().optional().describe("Make track private"), slug: z.string().optional().describe("URL-friendly slug"), }), view_tracks: z.object({}), get_track: z.object({ trackId: z.string().min(1).describe("The ID of the track"), }), add_course_to_track: z.object({ trackId: z.string().min(1).describe("The ID of the track"), courseId: z.string().min(1).describe("The ID of the course to add"), }), enroll_in_track: z.object({ trackId: z.string().min(1).describe("The ID of the track to enroll in"), }), search_reddit: z.object({ query: z.string().min(1).max(500).describe("Search query"), subreddit: z.string().optional().describe("Specific subreddit to search (optional)"), sort: z.enum(["relevance", "hot", "new", "top"]).default("relevance").describe("Sort order for results"), time: z.enum(["hour", "day", "week", "month", "year", "all"]).default("all").describe("Time filter for results"), limit: z.number().int().min(1).max(100).default(25).describe("Maximum number of results") }), get_channel: z.object({ subreddit: z.string().min(1).describe("Name of the subreddit (without r/ prefix)"), sort: z.enum(["hot", "new", "controversial"]).default("hot").describe("Sort order for posts") }), get_post: z.object({ id: z.string().describe("The unique identifier of the post to retrieve") }), get_notifications: z.object({ filter: z.enum(["all", "unread", "messages", "comments", "mentions"]).optional().describe("Filter notifications"), limit: z.number().int().min(1).max(100).default(25).describe("Maximum number of notifications"), markRead: z.boolean().optional().describe("Mark notifications as read"), excludeIds: z.array(z.string()).optional().describe("IDs to exclude"), excludeTypes: z.array(z.enum(["comment_reply", "post_reply", "username_mention", "message", "other"])).optional().describe("Types to exclude"), excludeSubreddits: z.array(z.string()).optional().describe("Subreddits to exclude"), after: z.string().optional().describe("Cursor for pagination"), before: z.string().optional().describe("Cursor for pagination") }), get_comment: z.object({ id: z.string().describe("The unique identifier of the comment"), includeThread: z.boolean().optional().describe("Include full comment thread") }), elicitation_example: z.object({ type: z.enum(["input", "confirm", "choice"]).describe("Type of elicitation"), prompt: z.string().describe("Prompt to show to user"), options: z.array(z.string()).optional().describe("Options for choice type") }), sampling_example: z.object({ taskType: z.enum(['summarize', 'generate', 'analyze', 'translate']).describe("The type of sampling task to demonstrate"), content: z.string().describe("Input content for the sampling task"), targetLanguage: z.string().optional().describe("Target language for translation tasks"), style: z.enum(['formal', 'casual', 'technical', 'creative']).optional().describe("Style preferences for generation tasks") }), structured_data_example: z.object({ dataType: z.enum(['user', 'analytics', 'weather', 'product']).describe('The type of structured data to return'), id: z.string().optional().describe('Optional ID to fetch specific data'), includeNested: z.boolean().optional().default(false).describe('Whether to include nested data structures'), simulateError: z.boolean().optional().default(false).describe('Whether to simulate validation errors for testing') }).strict(), mcp_logging: z.object({ level: z.enum(["debug", "info", "warning", "error"]).describe("Log level"), message: z.string().describe("Message to log"), data: z.unknown().optional().describe("Optional additional data") }), validation_example: z.object({ name: z.string().min(2).max(50).regex(/^[a-zA-Z ]+$/).describe("Name (letters and spaces only, 2-50 chars)"), age: z.number().int().min(0).max(150).describe("Age in years (0-150)"), email: z.string().email().describe("Valid email address"), role: z.enum(["user", "admin", "moderator"]).describe("User role"), preferences: z.object({ theme: z.enum(["light", "dark", "auto"]).optional().default("auto"), notifications: z.boolean().optional().default(true) }).optional(), tags: z.array(z.string().min(1)).min(0).max(10).optional().describe("List of tags (max 10, unique)") }) }; /** * Type mapping of tool names to their argument types. * * @remarks * This type ensures type safety when dispatching tool calls * to their respective handlers. */ type ToolArgs = { create_brainloop: any; view_brainloops: any; get_brainloop: any; expand_brainloop: any; brainloop_progress: any; get_unit_lessons: any; get_lesson: any; create_interaction: any; create_prompt: any; create_prompts_batch: any; get_lesson_prompts: any; update_lesson: any; update_unit: any; }; /** * Handles MCP tool listing requests. * * @remarks * Returns all available tools sorted alphabetically by name. * This allows MCP clients to discover what tools are available * for interacting with Reddit. * * @param _request - The tool listing request (currently unused) * @returns Promise resolving to the list of available tools * * @see {@link https://modelcontextprotocol.io/specification/2025-06-18/core/tools#listing-tools | Listing Tools} */ export async function handleListTools(_request: ListToolsRequest): Promise<ListToolsResult> { try { logger.info(`🔧 handleListTools called, TOOLS.length: ${TOOLS.length}`); const tools = [...TOOLS].sort((a, b) => a.name.localeCompare(b.name)); logger.info(`✅ Returning ${tools.length} tools: ${tools.map(t => t.name).join(', ')}`); return { tools }; } catch (error) { logger.error("Failed to list tools", { error: error instanceof Error ? error.message : String(error), }); return { tools: TOOLS }; } } /** * BRAINLOOP authentication credentials structure. * * @remarks * Contains the OAuth token and user ID needed to authenticate * requests to the BRAINLOOP API. */ interface BrainloopCredentials { /** OAuth2 access token for API requests */ accessToken: string; /** User ID */ userId: string; } /** * Extracts and validates BRAINLOOP credentials from AuthInfo. * * @remarks * This function ensures that all required authentication data * is present before attempting to make BRAINLOOP API calls. * * @param authInfo - Authentication information from the MCP context * @returns Validated BRAINLOOP credentials * @throws Error if required credentials are missing or invalid */ function extractAndValidateCredentials(authInfo: RedditAuthInfo): BrainloopCredentials { if (!authInfo.token) { throw new Error("Authentication failed: Missing access token"); } const userId = (authInfo.extra?.userId as string) || 'unknown'; try { return { accessToken: authInfo.token, userId, }; } catch (error) { logger.error("BRAINLOOP credentials validation failed", { error: error instanceof Error ? error.message : String(error), userId, }); throw error; } } /** * Handles MCP tool invocation requests. * * @remarks * This is the main dispatcher for tool calls. It: * 1. Validates the requested tool exists * 2. Extracts and validates authentication credentials * 3. Validates tool arguments against the tool's input schema * 4. Creates a Reddit service instance * 5. Dispatches to the appropriate tool handler * 6. Returns the tool result or error * * Tools that require content generation (like create_post) will trigger * the sampling feature and return an async processing message. * * @param request - The tool invocation request containing tool name and arguments * @param context - MCP context containing authentication and session information * @returns Promise resolving to the tool execution result * @throws Error if tool is unknown, auth fails, or execution fails * * @see {@link https://modelcontextprotocol.io/specification/2025-06-18/core/tools#calling-tools | Calling Tools} */ export async function handleToolCall( request: CallToolRequest, context: MCPToolContext, ): Promise<CallToolResult> { try { logger.info(`🔧 handleToolCall called for tool: ${request.params.name}`); // Extract and validate BRAINLOOP credentials from AuthInfo const credentials = extractAndValidateCredentials(context.authInfo); if (!request.params.arguments) { logger.error("Tool call missing required arguments", { toolName: request.params?.name }); throw new Error("Arguments are required"); } const tool = TOOLS.find((t) => t.name === request.params.name); if (!tool) { logger.error("Unknown tool requested", { toolName: request.params.name }); throw new Error(`${TOOL_ERROR_MESSAGES.UNKNOWN_TOOL} ${request.params.name}`); } // Validate arguments using Zod schema const toolName = request.params.name as keyof typeof ToolSchemas; const schema = ToolSchemas[toolName]; if (!schema) { logger.error("No Zod schema found for tool", { toolName }); throw new Error(`No validation schema found for tool: ${toolName}`); } let args: ToolArgs[keyof ToolArgs]; try { const validatedArgs = schema.parse(request.params.arguments); args = validatedArgs as ToolArgs[keyof ToolArgs]; } catch (error) { if (error instanceof z.ZodError) { logger.error("Tool argument validation failed", { toolName, errors: error.errors, arguments: request.params.arguments }); throw new Error(`Invalid arguments for tool ${toolName}: ${JSON.stringify(error.errors)}`); } throw error; } let result: CallToolResult; logger.info(`🔧 [TOOL CALL START] ${toolName}`, { args, userId: credentials.userId, sessionId: context.sessionId }); // Create brainloop service for brainloop tools const brainloopService = new BrainloopService({ accessToken: credentials.accessToken, userId: credentials.userId, refreshTokenCallback: context.refreshTokenCallback, }); logger.info(`✅ [BRAINLOOP SERVICE] Created for user`, { userId: credentials.userId }); const brainloopContext = { brainloopService, userId: credentials.userId, sessionId: context.sessionId, }; logger.info(`🎯 [TOOL DISPATCH] Calling handler for ${toolName}`); switch (request.params.name) { case "create_brainloop": result = await handleCreateBrainloop(args as any, brainloopContext); break; case "view_brainloops": result = await handleViewBrainloops(args, brainloopContext); break; case "get_brainloop": result = await handleGetBrainloop(args as any, brainloopContext); break; case "expand_brainloop": result = await handleExpandBrainloop(args as any, brainloopContext); break; case "brainloop_progress": result = await handleBrainloopProgress(args as any, brainloopContext); break; case "get_unit_lessons": result = await handleGetUnitLessons(args as any, brainloopContext); break; case "get_lesson": result = await handleGetLesson(args as any, brainloopContext); break; case "create_interaction": result = await handleCreateInteraction(args as any, brainloopContext); break; case "create_prompt": result = await handleCreatePrompt(args as any, brainloopContext); break; case "create_prompts_batch": result = await handleCreatePromptsBatch(args as any, brainloopContext); break; case "get_lesson_prompts": result = await handleGetLessonPrompts(args as any, brainloopContext); break; case "update_lesson": result = await handleUpdateLesson(args as any, brainloopContext); break; case "update_unit": result = await handleUpdateUnit(args as any, brainloopContext); break; case "detect_duplicates": result = await handleDetectDuplicates(args as any, brainloopContext); break; case "cleanup_empty_content": result = await handleCleanupEmptyContent(args as any, brainloopContext); break; case "reorder_units": result = await handleReorderUnits(args as any, brainloopContext); break; // BrainTrack tools case "create_track": result = await handleCreateTrack(args as any, brainloopContext); break; case "view_tracks": result = await handleViewTracks(args, brainloopContext); break; case "get_track": result = await handleGetTrack(args as any, brainloopContext); break; case "add_course_to_track": result = await handleAddCourseToTrack(args as any, brainloopContext); break; case "enroll_in_track": result = await handleEnrollInTrack(args as any, brainloopContext); break; default: logger.error("Unsupported tool in switch statement", { toolName: request.params.name }); throw new Error(`${TOOL_ERROR_MESSAGES.UNKNOWN_TOOL} ${request.params.name}`); } logger.info(`✅ [TOOL CALL COMPLETE] ${toolName}`, { hasResult: !!result, contentBlocks: result?.content?.length || 0 }); return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); const errorStack = error instanceof Error ? error.stack : undefined; logger.error(`❌ [TOOL CALL FAILED] ${request.params.name}`, { error: errorMessage, stack: errorStack, args: request.params.arguments, sessionId: context.sessionId }); logger.error("Tool call failed", { toolName: request.params?.name, error: errorMessage, stack: error instanceof Error ? error.stack : undefined, }); // Re-throw the error to be handled by MCP framework throw error; } }

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/ozipi/brainloop-mcp-server-v2'

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