Skip to main content
Glama

Learning Hour MCP

by SDiamante13
MiroIntegration.ts21.7 kB
import axios from 'axios'; import FormData from 'form-data'; import { CodeImageGenerator } from './CodeImageGenerator'; import { logger } from './logger'; import { ContentSizer } from './ContentSizer'; import { ContentPostProcessor } from './ContentPostProcessor'; interface MiroBoard { id: string; name: string; description?: string; createdAt: string; modifiedAt: string; viewLink: string; } interface MiroStickyNote { id: string; type: 'sticky_note'; data: { content: string; shape: 'square' | 'rectangle'; }; style: { fillColor: string; }; position: { x: number; y: number; }; } interface MiroFrame { id: string; type: 'text'; data: { content: string; }; position: { x: number; y: number; }; geometry: { width: number; height: number; }; } interface MiroSection { type: 'text_frame' | 'code_block' | 'code_examples' | 'sticky_notes'; title: string; content?: string; code?: string; beforeCode?: string; afterCode?: string; language?: string; items?: string[]; color?: string; } interface SessionContent { sessionOverview: string; miroContent: { boardTitle: string; style?: 'slide' | 'vertical'; sections: MiroSection[]; }; } interface LearningHourMiroLayout { boardId: string; viewLink?: string; sections: { overview: MiroFrame; objectives: MiroStickyNote[]; activities: MiroStickyNote[]; discussions: MiroStickyNote[]; takeaways: MiroStickyNote[]; }; } type Theme = "breeze" | "candy" | "crimson" | "falcon" | "meadow" | "midnight" | "raindrop" | "sunset"; export class MiroIntegration { private readonly miroApiUrl = 'https://api.miro.com/v2'; private readonly accessToken: string; private codeImageGenerator: CodeImageGenerator; private contentSizer: ContentSizer; private contentPostProcessor: ContentPostProcessor; constructor(accessToken: string) { this.accessToken = accessToken; this.codeImageGenerator = new CodeImageGenerator(); this.contentSizer = new ContentSizer(); this.contentPostProcessor = new ContentPostProcessor(); } async listBoards(limit: number = 50, cursor?: string): Promise<{ data: MiroBoard[], cursor?: string }> { try { const params: any = { limit }; if (cursor) { params.cursor = cursor; } const response = await axios.get(`${this.miroApiUrl}/boards`, { headers: { 'authorization': `Bearer ${this.accessToken}`, }, params }); return { data: response.data.data || [], cursor: response.data.cursor }; } catch (error: any) { throw new Error(`Failed to list boards: ${error.response?.data?.message || error.message}`); } } async getBoardInfo(boardId: string): Promise<MiroBoard> { try { const response = await axios.get(`${this.miroApiUrl}/boards/${boardId}`, { headers: { 'authorization': `Bearer ${this.accessToken}`, } }); return response.data; } catch (error) { throw new Error(`Failed to get board info: ${error instanceof Error ? error.message : String(error)}`); } } async createFrame(boardId: string, x: number, y: number, width: number = 400, height: number = 200): Promise<MiroFrame> { try { const response = await axios.post(`${this.miroApiUrl}/boards/${boardId}/frames`, { data: {}, style: { fillColor: '#ffffff' }, position: { x: x, y: y }, geometry: { width: width, height: height } }, { headers: { 'authorization': `Bearer ${this.accessToken}`, 'content-type': 'application/json' } }); return response.data; } catch (error: any) { logger.error('Text frame creation error:', { status: error.response?.status, statusText: error.response?.statusText, data: error.response?.data, fields: error.response?.data?.context?.fields, validations: error.response?.data?.context.validationErrors, message: error.message }); throw new Error(`Failed to create text frame: ${error.response?.data?.message || error.message}`); } } async createTextBox( boardId: string, content: string, x: number, y: number, width: number, fillColor: string = '#F7F7F7', fontSize: number = 16, fontWeight: 'normal' | 'bold' = 'normal' ): Promise<any> { try { const requestBody: any = { data: { content: content }, style: { color: "#1a1a1a", fillColor: fillColor, fontFamily: "arial", fontSize: fontSize, textAlign: "left" }, position: { x: x, y: y }, geometry: { rotation: 0, width: width }, }; const response = await axios.post(`${this.miroApiUrl}/boards/${boardId}/texts`, requestBody, { headers: { 'authorization': `Bearer ${this.accessToken}`, 'content-type': 'application/json', 'accept': 'application/json' } }); return response.data; } catch (error: any) { logger.error('Textbox creation error:', { status: error.response?.status, statusText: error.response?.statusText, data: error.response?.data, fields: error.response?.data?.context?.fields, message: error.message }); throw new Error(`Failed to create textbox: ${error.response?.data?.message || error.message}`); } } async createStickyNote(boardId: string, content: string, x: number, y: number, color: string = 'light_yellow'): Promise<MiroStickyNote> { try { const response = await axios.post(`${this.miroApiUrl}/boards/${boardId}/sticky_notes`, { data: { content: content }, style: { fillColor: color }, position: { x: x, y: y } }, { headers: { 'authorization': `Bearer ${this.accessToken}`, 'content-type': 'application/json' } }); return response.data; } catch (error: any) { logger.error('Sticky note creation error:', { status: error.response?.status, statusText: error.response?.statusText, data: error.response?.data, fields: error.response?.data?.context?.fields, message: error.message }); throw new Error(`Failed to create sticky note: ${error.response?.data?.message || error.message}`); } } async createCodeBlock(boardId: string, code: string, x: number, y: number, language: string = 'javascript', width: number = 800, height: number = 400, title?: string, theme: Theme = 'sunset'): Promise<any> { try { const cleanCode = this.codeImageGenerator.cleanCodeSnippet(code); const codeImage = await this.codeImageGenerator.generateCodeImage({ code: cleanCode, language: language, theme: theme, darkMode: true, padding: 32, title: title }); if (codeImage && codeImage.buffer) { // Upload image using multipart form data const formData = new FormData(); formData.append('resource', codeImage.buffer, { filename: `code-${Date.now()}.png`, contentType: 'image/png' }); // Add position and geometry as form fields formData.append('data', JSON.stringify({ position: { x: x, y: y }, geometry: { width: width, height: height } })); const response = await axios.post(`${this.miroApiUrl}/boards/${boardId}/images`, formData, { headers: { 'authorization': `Bearer ${this.accessToken}`, ...formData.getHeaders() }, maxContentLength: Infinity, maxBodyLength: Infinity }); return response.data; } else { // Fallback to text-based approach logger.info('Using text-based code block (no image buffer available)'); return this.createTextBasedCodeBlock(boardId, cleanCode, x, y, width, height); } } catch (error: any) { logger.error('Code block creation error:', { status: error.response?.status, statusText: error.response?.statusText, data: error.response?.data, fields: error.response?.data?.context?.fields, message: error.message }); throw new Error(`Failed to create code block: ${error.response?.data?.message || error.message}`); } } private async createTextBasedCodeBlock(boardId: string, code: string, x: number, y: number, width: number, height: number): Promise<any> { await axios.post(`${this.miroApiUrl}/boards/${boardId}/shapes`, { data: { content: code, shape: 'rectangle' }, style: { fillColor: "#1e1e1e", borderWidth: 2 }, position: { x: x, y: y }, geometry: { width: width, height: height } }, { headers: { 'authorization': `Bearer ${this.accessToken}`, 'content-type': 'application/json' } }); const response = await axios.post(`${this.miroApiUrl}/boards/${boardId}/texts`, { data: { content: code }, style: { color: "#d4d4d4", fillColor: "#ffffff", fontFamily: "arial", fontSize: 12, textAlign: "left" }, position: { x: x + 20, y: y + 20 }, geometry: { width: width - 40 } }, { headers: { 'authorization': `Bearer ${this.accessToken}`, 'content-type': 'application/json' } }); return response.data; } async createLearningHourBoard(sessionContent: SessionContent): Promise<LearningHourMiroLayout> { // Post-process content to replace placeholders const processedContent = this.contentPostProcessor.processSessionContent(sessionContent); const boardName = processedContent.miroContent.boardTitle; const board = await this.createBoard(boardName, processedContent.sessionOverview); const style = processedContent.miroContent.style ?? 'slide'; const layout: LearningHourMiroLayout = { boardId: board.id, viewLink: board.viewLink, sections: { overview: {} as MiroFrame, objectives: [], activities: [], discussions: [], takeaways: [] } }; if (style === 'slide') { return await this.createSlideLayout(board.id, processedContent, layout); } else { return await this.createVerticalLayout(board.id, processedContent, layout); } } private async createBoard(name: string, description?: string): Promise<MiroBoard> { try { const requestBody: any = { name }; if (description) { requestBody.description = description; } const response = await axios.post(`${this.miroApiUrl}/boards`, requestBody, { headers: { 'authorization': `Bearer ${this.accessToken}`, 'content-type': 'application/json' } }); return response.data; } catch (error) { if (axios.isAxiosError(error)) { const status = error.response?.status; const data = error.response?.data; throw new Error(`Failed to create Miro board: ${status} - ${JSON.stringify(data)}`); } throw new Error(`Failed to create Miro board: ${error instanceof Error ? error.message : String(error)}`); } } async addFramesToExistingBoard(boardId: string, sessionContent: SessionContent): Promise<LearningHourMiroLayout> { // Post-process content to replace placeholders const processedContent = this.contentPostProcessor.processSessionContent(sessionContent); logger.info('Session content structure:', { hasMiroContent: !!processedContent.miroContent, miroContentKeys: processedContent.miroContent ? Object.keys(processedContent.miroContent) : [], hasSections: processedContent.miroContent?.sections !== undefined, sectionsType: Array.isArray(processedContent.miroContent?.sections) ? 'array' : typeof processedContent.miroContent?.sections, sectionsLength: Array.isArray(processedContent.miroContent?.sections) ? processedContent.miroContent.sections.length : 'N/A' }); const style = processedContent.miroContent?.style ?? 'slide'; // Get board info to find a good position for new content const boardInfo = await this.getBoardInfo(boardId); const layout: LearningHourMiroLayout = { boardId: boardId, viewLink: boardInfo.viewLink, sections: { overview: {} as MiroFrame, objectives: [], activities: [], discussions: [], takeaways: [] } }; // Add frames to the existing board if (style === 'slide') { return await this.createSlideLayout(boardId, processedContent, layout); } else { return await this.createVerticalLayout(boardId, processedContent, layout); } } private async createSlideLayout(boardId: string, content: SessionContent, layout: LearningHourMiroLayout): Promise<LearningHourMiroLayout> { const slideSpacing = 1000; let currentSlideX = 0; // Ensure sections exists and is iterable const sections = content.miroContent?.sections || []; if (!Array.isArray(sections)) { throw new Error('miroContent.sections must be an array'); } for (const section of sections) { const slideX = currentSlideX; const slideY = 0; // Create title text box for this section with larger font await this.createTextBox( boardId, section.title, slideX - 50, slideY - 300, 800, '#ffffff', 28, // Larger font for titles 'bold' ); // Calculate dynamic frame size based on content const titleFrameDimensions = this.contentSizer.calculateFrameDimensions(undefined, section.title); // Create frame for section title await this.createFrame(boardId, slideX, slideY - 250, titleFrameDimensions.width, titleFrameDimensions.height); if (section.type === 'text_frame') { // Calculate frame size based on actual content const contentFrameDimensions = this.contentSizer.calculateFrameDimensions(section.content, section.title); const contentFrame = await this.createFrame( boardId, slideX, slideY - 100, contentFrameDimensions.width, contentFrameDimensions.height ); if (section.content) { await this.createTextBox( boardId, section.content, slideX + 40, // Padding from frame left edge slideY - 60, // Padding from frame top edge contentFrameDimensions.width - 80, // Width with padding '#ffffff', // White background 18, // Readable font size for content 'normal' ); } if (section.title === 'Session Overview') { layout.sections.overview = contentFrame; } } else if (section.type === 'code_block' && section.code) { await this.createCodeBlock( boardId, section.code, slideX, slideY - 100, section.language || 'javascript', 700, 300 ); } else if (section.type === 'code_examples' && section.beforeCode && section.afterCode) { await this.createTextBox(boardId, 'Before', slideX - 350, slideY - 300, 100, '#FFE066', 20, 'bold'); await this.createTextBox(boardId, 'After', slideX + 250, slideY - 300, 100, '#06D6A0', 20, 'bold'); await this.createCodeBlock(boardId, section.beforeCode, slideX - 350, slideY - 50, section.language || 'javascript', 400, 350, 'Before', 'midnight'); await this.createCodeBlock(boardId, section.afterCode, slideX + 250, slideY - 50, section.language || 'javascript', 400, 350, 'After', 'midnight'); } else if (section.type === 'sticky_notes' && section.items) { const stickyNotes = await this.createStickyNotesGrid( boardId, section.items, slideX, slideY, section.color ?? 'light_yellow' ); this.assignStickyNotesToSection(layout, section.title, stickyNotes); } currentSlideX += slideSpacing; } return layout; } private async createVerticalLayout(boardId: string, content: SessionContent, layout: LearningHourMiroLayout): Promise<LearningHourMiroLayout> { let currentY = -400; const sectionSpacing = 300; // Ensure sections exists and is iterable const sections = content.miroContent?.sections || []; if (!Array.isArray(sections)) { throw new Error('miroContent.sections must be an array'); } for (const section of sections) { const sectionY = currentY; currentY += sectionSpacing; // Calculate dynamic frame size based on content const titleFrameDimensions = this.contentSizer.calculateFrameDimensions(undefined, section.title); // Create title text box for vertical layout with larger font await this.createTextBox( boardId, section.title, -500, sectionY - 50, titleFrameDimensions.width - 100, '#ffffff', 28, // Larger font for titles 'bold' ); await this.createFrame(boardId, -500, sectionY, titleFrameDimensions.width, titleFrameDimensions.height); if (section.type === 'text_frame') { // Calculate frame size based on actual content const contentFrameDimensions = this.contentSizer.calculateFrameDimensions(section.content, section.title); const textFrame = await this.createFrame( boardId, -200, sectionY, contentFrameDimensions.width, contentFrameDimensions.height ); if (section.content) { await this.createTextBox( boardId, section.content, -160, // Padding from frame left edge sectionY + 40, // Padding from frame top edge contentFrameDimensions.width - 80, // Width with padding '#ffffff', // White background 18, // Readable font size for content 'normal' ); } if (section.title === 'Session Overview') { layout.sections.overview = textFrame; } currentY += contentFrameDimensions.height + 50; // Dynamic spacing based on content } else if (section.type === 'code_block' && section.code) { await this.createCodeBlock( boardId, section.code, -200, sectionY, section.language || 'javascript', 600, 300 ); currentY += 350; } else if (section.type === 'code_examples' && section.beforeCode && section.afterCode) { await this.createTextBox(boardId, 'Before', -600, sectionY, 100, '#FFE066', 20, 'bold'); await this.createTextBox(boardId, 'After', 100, sectionY, 100, '#06D6A0', 20, 'bold'); await this.createCodeBlock(boardId, section.beforeCode, -600, sectionY + 50, section.language || 'javascript', 400, 350, 'Before', 'midnight'); await this.createCodeBlock(boardId, section.afterCode, 100, sectionY + 50, section.language || 'javascript', 400, 350, 'After', 'midnight'); currentY += 450; } else if (section.type === 'sticky_notes' && section.items) { const stickyNotes: MiroStickyNote[] = []; let currentX = -400; for (const item of section.items) { const note = await this.createStickyNote( boardId, item, currentX, sectionY, section.color ); stickyNotes.push(note); currentX += 120; } this.assignStickyNotesToSection(layout, section.title, stickyNotes); } } return layout; } private async createStickyNotesGrid(boardId: string, items: string[], centerX: number, centerY: number, color: string): Promise<MiroStickyNote[]> { const stickyNotes: MiroStickyNote[] = []; const itemsPerRow = Math.min(3, items.length); const rows = Math.ceil(items.length / itemsPerRow); const itemSpacingX = 200; const itemSpacingY = 140; const startX = centerX - ((itemsPerRow - 1) * itemSpacingX) / 2; const startY = centerY - ((rows - 1) * itemSpacingY) / 2; for (let i = 0; i < items.length; i++) { const row = Math.floor(i / itemsPerRow); const col = i % itemsPerRow; const itemX = startX + (col * itemSpacingX); const itemY = startY + (row * itemSpacingY); const note = await this.createStickyNote( boardId, `• ${items[i]}`, itemX, itemY, color ); stickyNotes.push(note); } return stickyNotes; } private assignStickyNotesToSection(layout: LearningHourMiroLayout, sectionTitle: string, stickyNotes: MiroStickyNote[]): void { switch (sectionTitle) { case 'Learning Objectives': layout.sections.objectives = stickyNotes; break; case 'Activities': layout.sections.activities = stickyNotes; break; case 'Discussion Questions': layout.sections.discussions = stickyNotes; break; case 'Key Takeaways': layout.sections.takeaways = stickyNotes; break; } } }

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/SDiamante13/learning-hour-mcp'

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