Skip to main content
Glama

GitHub Calendar MCP Server

index.js56.2 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { Octokit } from '@octokit/rest'; import { format, addDays, parseISO, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth } from 'date-fns'; import { createUIResource } from '@mcp-ui/server'; // GitHub configuration const DEFAULT_ORG = 'squareup'; const DEFAULT_PROJECT_NUMBER = 333; const DEFAULT_LABEL = 'area: devrel-opensource'; class GitHubCalendarMCPServer { constructor() { this.server = new Server( { name: 'github-calendar-mcp-server', version: '1.0.0', }, { capabilities: { tools: {}, }, } ); // Initialize GitHub API client this.octokit = new Octokit({ auth: process.env.GITHUB_TOKEN, }); this.setupToolHandlers(); } // GitHub GraphQL query for Projects v2 getProjectV2Query() { return ` query($org: String!, $projectNumber: Int!, $cursor: String) { organization(login: $org) { projectV2(number: $projectNumber) { id title items(first: 100, after: $cursor) { pageInfo { hasNextPage endCursor } nodes { id type content { ... on Issue { id number title body state createdAt updatedAt closedAt url author { login avatarUrl } labels(first: 20) { nodes { id name color description } } assignees(first: 10) { nodes { login avatarUrl } } milestone { title description dueOn } } } fieldValues(first: 20) { nodes { ... on ProjectV2ItemFieldDateValue { field { ... on ProjectV2FieldCommon { id name } } date } ... on ProjectV2ItemFieldTextValue { field { ... on ProjectV2FieldCommon { id name } } text } ... on ProjectV2ItemFieldSingleSelectValue { field { ... on ProjectV2FieldCommon { id name } } name } ... on ProjectV2ItemFieldNumberValue { field { ... on ProjectV2FieldCommon { id name } } number } } } } } } } } `; } async fetchProjectItems(org = DEFAULT_ORG, projectNumber = DEFAULT_PROJECT_NUMBER, sinceDate) { const since = sinceDate || new Date('2025-08-01'); try { console.error('Attempting to use GraphQL API for Projects v2...'); return await this.fetchProjectItemsGraphQL(org, projectNumber, since); } catch (graphqlError) { console.error('GraphQL API failed, falling back to Search API:', graphqlError); return await this.fetchIssuesByLabel(org, since); } } async fetchProjectItemsGraphQL(org, projectNumber, since) { const allItems = []; let cursor = null; let hasNextPage = true; while (hasNextPage) { const response = await this.octokit.graphql(this.getProjectV2Query(), { org, projectNumber, cursor, }); const project = response.organization.projectV2; if (!project) { throw new Error(`Project ${projectNumber} not found for organization ${org}`); } const items = project.items.nodes; // Filter items based on date and label const filteredItems = items.filter((item) => { if (!item.content || item.type !== 'ISSUE') return false; // Check if issue has the required label const hasRequiredLabel = item.content.labels?.nodes?.some( (label) => label.name === DEFAULT_LABEL ); if (!hasRequiredLabel) return false; // Check if issue was created after our since date const createdAt = new Date(item.content.createdAt); return createdAt >= since; }); // Transform GraphQL response to our format const transformedItems = filteredItems.map((item) => ({ id: item.id, content: { id: item.content.id, number: item.content.number, title: item.content.title, body: item.content.body || '', state: item.content.state.toLowerCase(), created_at: item.content.createdAt, updated_at: item.content.updatedAt, closed_at: item.content.closedAt, html_url: item.content.url, user: { login: item.content.author?.login || '', avatar_url: item.content.author?.avatarUrl || '', }, labels: item.content.labels?.nodes?.map((label) => ({ id: label.id, name: label.name, color: label.color, description: label.description, })) || [], assignees: item.content.assignees?.nodes?.map((assignee) => ({ login: assignee.login, avatar_url: assignee.avatarUrl, })) || [], milestone: item.content.milestone ? { title: item.content.milestone.title, description: item.content.milestone.description, due_on: item.content.milestone.dueOn, } : null, }, fieldValues: { nodes: item.fieldValues.nodes.map((fieldValue) => ({ field: { name: fieldValue.field?.name || '', }, date: fieldValue.date || null, text: fieldValue.text || null, name: fieldValue.name || null, number: fieldValue.number || null, })), }, })); allItems.push(...transformedItems); hasNextPage = project.items.pageInfo.hasNextPage; cursor = project.items.pageInfo.endCursor; } console.error(`Successfully fetched ${allItems.length} items using GraphQL API`); return allItems; } // Fallback function to search issues by label directly async fetchIssuesByLabel(org, since) { const allItems = []; let page = 1; const perPage = 100; let hasNextPage = true; while (hasNextPage) { try { // Search for issues with the specific label in the organization const { data: searchResult } = await this.octokit.rest.search.issuesAndPullRequests({ q: `org:${org} label:"${DEFAULT_LABEL}" type:issue created:>=${since.toISOString().split('T')[0]}`, per_page: perPage, page: page, }); if (searchResult.items.length === 0) { hasNextPage = false; break; } for (const issue of searchResult.items) { const projectItem = { id: issue.id.toString(), content: { id: issue.id, number: issue.number, title: issue.title, body: issue.body || '', state: issue.state, created_at: issue.created_at, updated_at: issue.updated_at, closed_at: issue.closed_at, html_url: issue.html_url, user: { login: issue.user?.login || '', avatar_url: issue.user?.avatar_url || '', }, labels: issue.labels.map((label) => ({ id: label.id, name: label.name, color: label.color, description: label.description, })), assignees: issue.assignees?.map((assignee) => ({ login: assignee.login, avatar_url: assignee.avatar_url, })) || [], milestone: issue.milestone ? { title: issue.milestone.title, description: issue.milestone.description, due_on: issue.milestone.due_on, } : null, }, fieldValues: { nodes: [], }, }; allItems.push(projectItem); } page++; hasNextPage = searchResult.items.length === perPage; } catch (searchError) { console.error('Error searching issues:', searchError); break; } } return allItems; } transformToCalendarEvents(items) { return items.map((item) => { const issue = item.content; // Extract start and end dates from field values let startDate = null; let endDate = null; let projectStatus = undefined; item.fieldValues.nodes.forEach((fieldValue) => { const fieldName = fieldValue.field.name.toLowerCase(); if (fieldName.includes('start') && fieldValue.date) { startDate = new Date(fieldValue.date); } else if ((fieldName.includes('end') || fieldName.includes('due')) && fieldValue.date) { endDate = new Date(fieldValue.date); } else if ((fieldName.includes('status') || fieldName.includes('state') || fieldName.includes('progress')) && fieldValue.name) { projectStatus = fieldValue.name; } }); // Try to extract dates from issue body if not found in field values if (!startDate || !endDate) { const body = issue.body || ''; // Look for date patterns in the issue body const startDateMatch = body.match(/\*\*Start Date:\*\*.*?\(([^)]+)\)/); const endDateMatch = body.match(/\*\*End Date:\*\*.*?\(([^)]+)\)/); if (startDateMatch && !startDate) { startDate = new Date(startDateMatch[1]); } if (endDateMatch && !endDate) { endDate = new Date(endDateMatch[1]); } } // If no start date from fields or body, use created date if (!startDate) { startDate = new Date(issue.created_at); } // If issue has a milestone with due date, use that as end date if (!endDate && issue.milestone?.due_on) { endDate = new Date(issue.milestone.due_on); } return { id: `${issue.number}`, title: issue.title, startDate, endDate, url: issue.html_url, labels: issue.labels.map((label) => ({ name: label.name, color: `#${label.color}`, })), assignees: issue.assignees.map((assignee) => ({ login: assignee.login, avatar_url: assignee.avatar_url, })), status: issue.state, projectStatus, type: 'issue', }; }).filter((event) => event.startDate); // Only include events with start dates } async getCalendarEvents(org = DEFAULT_ORG, projectNumber = DEFAULT_PROJECT_NUMBER, sinceDate) { const items = await this.fetchProjectItems(org, projectNumber, sinceDate); return this.transformToCalendarEvents(items); } // Analyze team workload analyzeTeamWorkload(events) { const teamWorkload = {}; const now = new Date(); events.forEach(event => { if (event.status === 'closed') return; // Skip completed tasks event.assignees.forEach(assignee => { if (!teamWorkload[assignee.login]) { teamWorkload[assignee.login] = { login: assignee.login, avatar_url: assignee.avatar_url, activeIssues: 0, upcomingIssues: 0, overdueIssues: 0, totalWorkload: 0 }; } const member = teamWorkload[assignee.login]; member.activeIssues++; member.totalWorkload++; // Check if overdue if (event.endDate && event.endDate < now) { member.overdueIssues++; } // Check if upcoming (starts in future) if (event.startDate > now) { member.upcomingIssues++; } }); }); return Object.values(teamWorkload).sort((a, b) => a.totalWorkload - b.totalWorkload); } // Create calendar UI for events createCalendarUI(events, currentDate = new Date()) { const monthStart = startOfMonth(currentDate); const monthEnd = endOfMonth(currentDate); const calendarDays = eachDayOfInterval({ start: monthStart, end: monthEnd }); // Group events by date const eventsByDate = {}; events.forEach(event => { const eventDates = []; if (event.endDate && event.startDate.getTime() !== event.endDate.getTime()) { // Multi-day event - add to all days in range const eventStart = event.startDate > monthStart ? event.startDate : monthStart; const eventEnd = event.endDate < monthEnd ? event.endDate : monthEnd; if (eventStart <= monthEnd && eventEnd >= monthStart) { const eventInterval = eachDayOfInterval({ start: eventStart, end: eventEnd }); eventDates.push(...eventInterval); } } else { // Single day event if (isSameMonth(event.startDate, currentDate)) { eventDates.push(event.startDate); } } eventDates.forEach(date => { const dateKey = format(date, 'yyyy-MM-dd'); if (!eventsByDate[dateKey]) { eventsByDate[dateKey] = []; } eventsByDate[dateKey].push(event); }); }); // Color palette for assignees const assigneeColors = [ '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f97316', '#6366f1' ]; const uniqueAssignees = Array.from( new Set(events.flatMap(event => event.assignees.map(a => a.login))) ); const assigneeColorMap = {}; uniqueAssignees.forEach((login, index) => { assigneeColorMap[login] = assigneeColors[index % assigneeColors.length]; }); // Generate calendar grid const calendarGrid = calendarDays.map(day => { const dateKey = format(day, 'yyyy-MM-dd'); const dayEvents = eventsByDate[dateKey] || []; const isToday = isSameDay(day, new Date()); const eventItems = dayEvents.slice(0, 3).map(event => { const primaryAssignee = event.assignees[0]; const color = primaryAssignee ? assigneeColorMap[primaryAssignee.login] : '#6b7280'; const isCompleted = event.status === 'closed'; return ` <div class="event" style="background-color: ${color}${isCompleted ? '80' : ''}; color: white; font-size: 10px; padding: 2px 4px; margin: 1px 0; border-radius: 2px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;" title="${event.title}"> ${isCompleted ? '✓ ' : ''}${event.title} </div> `; }).join(''); const moreCount = dayEvents.length > 3 ? dayEvents.length - 3 : 0; const moreIndicator = moreCount > 0 ? `<div class="more" style="font-size: 9px; color: #666;">+${moreCount} more</div>` : ''; return ` <div class="calendar-day ${isToday ? 'today' : ''}" style="border: 1px solid #e5e7eb; padding: 4px; min-height: 100px; background: ${isToday ? '#eff6ff' : 'white'};"> <div class="day-number" style="font-weight: ${isToday ? 'bold' : 'normal'}; color: ${isToday ? '#3b82f6' : '#374151'}; margin-bottom: 4px;"> ${format(day, 'd')} </div> <div class="events"> ${eventItems} ${moreIndicator} </div> </div> `; }).join(''); // Generate assignee legend const assigneeLegend = uniqueAssignees.map(login => { const color = assigneeColorMap[login]; const assignee = events.find(e => e.assignees.some(a => a.login === login))?.assignees.find(a => a.login === login); return ` <div onclick="filterByAssignee('${login}')" style="display: flex; align-items: center; margin: 4px 8px; cursor: pointer; padding: 4px; border-radius: 4px; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#f3f4f6'" onmouseout="this.style.backgroundColor='transparent'"> <div style="width: 12px; height: 12px; background-color: ${color}; border-radius: 2px; margin-right: 8px;"></div> <img src="${assignee?.avatar_url || ''}" alt="${login}" style="width: 20px; height: 20px; border-radius: 50%; margin-right: 8px;"> <span style="font-size: 12px;">${login}</span> </div> `; }).join(''); return ` <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <style> body { font-family: system-ui, -apple-system, sans-serif; margin: 0; padding: 16px; background: #f8fafc; } .container { max-width: 1000px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); overflow: hidden; } .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; text-align: center; } .calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); } .calendar-header { display: grid; grid-template-columns: repeat(7, 1fr); background: #f8fafc; } .calendar-header-day { padding: 12px; text-align: center; font-weight: 600; color: #374151; border-bottom: 2px solid #e5e7eb; } .calendar-day { border-right: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb; } .calendar-day:nth-child(7n) { border-right: none; } .today { background-color: #eff6ff !important; } .legend { padding: 16px; background: #f8fafc; border-top: 1px solid #e5e7eb; } .legend-title { font-weight: 600; margin-bottom: 12px; color: #374151; } .legend-items { display: flex; flex-wrap: wrap; } .stats { padding: 16px; background: #f8fafc; display: flex; justify-content: space-around; text-align: center; } .stat { } .stat-number { font-size: 24px; font-weight: bold; color: #3b82f6; } .stat-label { font-size: 12px; color: #6b7280; } </style> </head> <body> <div class="container"> <div class="header"> <h2 style="margin: 0; font-size: 24px;">📅 GitHub Project Calendar</h2> <p style="margin: 8px 0 0 0; opacity: 0.9;">${format(currentDate, 'MMMM yyyy')} • DevRel Open Source</p> </div> <div class="stats"> <div class="stat"> <div class="stat-number">${events.length}</div> <div class="stat-label">Total Events</div> </div> <div class="stat"> <div class="stat-number">${events.filter(e => e.status === 'open').length}</div> <div class="stat-label">Open Issues</div> </div> <div class="stat"> <div class="stat-number">${events.filter(e => e.status === 'closed').length}</div> <div class="stat-label">Completed</div> </div> <div class="stat"> <div class="stat-number">${uniqueAssignees.length}</div> <div class="stat-label">Team Members</div> </div> </div> <div class="calendar-header"> <div class="calendar-header-day">Sun</div> <div class="calendar-header-day">Mon</div> <div class="calendar-header-day">Tue</div> <div class="calendar-header-day">Wed</div> <div class="calendar-header-day">Thu</div> <div class="calendar-header-day">Fri</div> <div class="calendar-header-day">Sat</div> </div> <div class="calendar-grid"> ${calendarGrid} </div> ${uniqueAssignees.length > 0 ? ` <div class="legend"> <div class="legend-title">Team Members</div> <div class="legend-items"> ${assigneeLegend} </div> </div> ` : ''} <div style="padding: 16px; background: #f8fafc; border-top: 1px solid #e5e7eb; display: flex; gap: 8px; flex-wrap: wrap;"> <button onclick="refreshCalendar()" style="background: #3b82f6; color: white; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 12px;">🔄 Refresh</button> <button onclick="showTeamStatus()" style="background: #10b981; color: white; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 12px;">👥 Team Status</button> <button onclick="analyzeWorkload()" style="background: #f59e0b; color: white; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 12px;">📊 Analyze Workload</button> <button onclick="findAssignee()" style="background: #8b5cf6; color: white; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 12px;">🎯 Find Assignee</button> </div> </div> <script> // Auto-resize the iframe to fit content new ResizeObserver(entries => { entries.forEach(entry => { window.parent.postMessage({ type: "ui-size-change", payload: { height: entry.contentRect.height + 50 } }, "*"); }); }).observe(document.documentElement); // Interactive functions function refreshCalendar() { window.parent.postMessage({ type: "prompt", payload: { prompt: "Refresh the calendar and show me updated events" } }, "*"); } function showTeamStatus() { window.parent.postMessage({ type: "prompt", payload: { prompt: "Show me the current team status" } }, "*"); } function analyzeWorkload() { window.parent.postMessage({ type: "prompt", payload: { prompt: "Analyze the team workload distribution" } }, "*"); } function findAssignee() { window.parent.postMessage({ type: "prompt", payload: { prompt: "Who should I assign a new task to?" } }, "*"); } function filterByAssignee(login) { window.parent.postMessage({ type: "prompt", payload: { prompt: \`Show me calendar events for \${login}\` } }, "*"); } function viewEventDetails(eventUrl) { window.parent.postMessage({ type: "link", payload: { url: eventUrl } }, "*"); } </script> </body> </html> `; } // Create person schedule UI createPersonScheduleUI(personEvents, login, days) { if (personEvents.length === 0) { return ` <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <style> body { font-family: system-ui, -apple-system, sans-serif; margin: 0; padding: 16px; background: #f8fafc; } .container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); overflow: hidden; } .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; text-align: center; } .empty-state { padding: 40px; text-align: center; color: #6b7280; } .empty-icon { font-size: 48px; margin-bottom: 16px; } </style> </head> <body> <div class="container"> <div class="header"> <h2 style="margin: 0; font-size: 24px;">📅 Schedule for ${login}</h2> <p style="margin: 8px 0 0 0; opacity: 0.9;">Next ${days} days</p> </div> <div class="empty-state"> <div class="empty-icon">🏖️</div> <h3 style="margin: 0 0 8px 0; color: #374151;">No upcoming work!</h3> <p style="margin: 0;">${login} has a clear schedule for the next ${days} days.</p> </div> </div> <script> new ResizeObserver(entries => { entries.forEach(entry => { window.parent.postMessage({ type: "ui-size-change", payload: { height: entry.contentRect.height + 50 } }, "*"); }); }).observe(document.documentElement); </script> </body> </html> `; } const eventCards = personEvents.map(event => { const startStr = format(event.startDate, 'MMM dd, yyyy'); const endStr = event.endDate ? format(event.endDate, 'MMM dd, yyyy') : 'No end date'; const statusColor = event.status === 'open' ? '#10b981' : '#6b7280'; const isOverdue = event.endDate && event.endDate < new Date(); const urgencyColor = isOverdue ? '#ef4444' : '#3b82f6'; return ` <div class="event-card" style="background: white; border-radius: 8px; padding: 16px; margin: 12px 0; box-shadow: 0 2px 8px rgba(0,0,0,0.1); border-left: 4px solid ${urgencyColor};"> <div style="display: flex; justify-content: between; align-items: start; margin-bottom: 12px;"> <h3 style="margin: 0; color: #1f2937; font-size: 16px; flex: 1;">${event.title}</h3> <span style="background: ${statusColor}; color: white; padding: 2px 8px; border-radius: 12px; font-size: 12px; font-weight: 500; margin-left: 12px;">${event.status}</span> </div> <div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin-bottom: 12px;"> <div> <div style="font-size: 12px; color: #6b7280; margin-bottom: 4px;">Start Date</div> <div style="font-weight: 500; color: #374151;">${startStr}</div> </div> <div> <div style="font-size: 12px; color: #6b7280; margin-bottom: 4px;">End Date</div> <div style="font-weight: 500; color: ${isOverdue ? '#ef4444' : '#374151'};">${endStr}</div> </div> </div> ${event.assignees.length > 1 ? ` <div style="margin-bottom: 12px;"> <div style="font-size: 12px; color: #6b7280; margin-bottom: 4px;">Collaborators</div> <div style="display: flex; gap: 4px;"> ${event.assignees.slice(1).map(assignee => ` <img src="${assignee.avatar_url}" alt="${assignee.login}" style="width: 24px; height: 24px; border-radius: 50%;" title="${assignee.login}"> `).join('')} </div> </div> ` : ''} <div style="display: flex; justify-content: space-between; align-items: center;"> ${isOverdue ? '<span style="color: #ef4444; font-size: 12px; font-weight: 500;">⚠️ Overdue</span>' : '<span></span>'} <a href="${event.url}" target="_blank" style="color: #3b82f6; text-decoration: none; font-size: 12px; font-weight: 500;">View Issue →</a> </div> </div> `; }).join(''); const stats = { total: personEvents.length, open: personEvents.filter(e => e.status === 'open').length, overdue: personEvents.filter(e => e.endDate && e.endDate < new Date()).length }; return ` <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <style> body { font-family: system-ui, -apple-system, sans-serif; margin: 0; padding: 16px; background: #f8fafc; } .container { max-width: 700px; margin: 0 auto; } .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; text-align: center; border-radius: 12px 12px 0 0; } .stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; padding: 16px; background: white; } .stat { text-align: center; padding: 12px; } .stat-number { font-size: 24px; font-weight: bold; color: #3b82f6; } .stat-label { font-size: 12px; color: #6b7280; } .events { background: #f8fafc; padding: 16px; border-radius: 0 0 12px 12px; } </style> </head> <body> <div class="container"> <div class="header"> <h2 style="margin: 0; font-size: 24px;">📅 Schedule for ${login}</h2> <p style="margin: 8px 0 0 0; opacity: 0.9;">Next ${days} days</p> </div> <div class="stats"> <div class="stat"> <div class="stat-number">${stats.total}</div> <div class="stat-label">Total Items</div> </div> <div class="stat"> <div class="stat-number" style="color: #10b981;">${stats.open}</div> <div class="stat-label">Active</div> </div> <div class="stat"> <div class="stat-number" style="color: ${stats.overdue > 0 ? '#ef4444' : '#6b7280'};">${stats.overdue}</div> <div class="stat-label">Overdue</div> </div> </div> <div class="events"> ${eventCards} </div> </div> <script> new ResizeObserver(entries => { entries.forEach(entry => { window.parent.postMessage({ type: "ui-size-change", payload: { height: entry.contentRect.height + 50 } }, "*"); }); }).observe(document.documentElement); </script> </body> </html> `; } // Create workload analysis UI createWorkloadAnalysisUI(workloadAnalysis) { const maxWorkload = Math.max(...workloadAnalysis.map(m => m.totalWorkload), 1); const analysisCards = workloadAnalysis.map((member, index) => { const workloadLevel = member.totalWorkload <= 2 ? 'Light' : member.totalWorkload <= 4 ? 'Moderate' : 'Heavy'; const levelColor = member.totalWorkload <= 2 ? '#10b981' : member.totalWorkload <= 4 ? '#f59e0b' : '#ef4444'; const workloadPercentage = (member.totalWorkload / maxWorkload) * 100; return ` <div class="analysis-card" style="background: white; border-radius: 8px; padding: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); margin: 8px 0;"> <div style="display: flex; align-items: center; margin-bottom: 16px;"> <div style="background: ${levelColor}; color: white; border-radius: 50%; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; font-weight: bold; margin-right: 12px;"> ${index + 1} </div> <img src="${member.avatar_url}" alt="${member.login}" style="width: 40px; height: 40px; border-radius: 50%; margin-right: 12px;"> <div style="flex: 1;"> <h3 style="margin: 0; font-size: 16px; color: #1f2937;">${member.login}</h3> <span style="background: ${levelColor}; color: white; padding: 2px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;">${workloadLevel}</span> </div> <div style="text-align: right;"> <div style="font-size: 24px; font-weight: bold; color: ${levelColor};">${member.totalWorkload}</div> <div style="font-size: 12px; color: #6b7280;">issues</div> </div> </div> <div style="margin-bottom: 12px;"> <div style="background: #f3f4f6; border-radius: 8px; height: 8px; overflow: hidden;"> <div style="background: ${levelColor}; height: 100%; width: ${workloadPercentage}%; transition: width 0.3s ease;"></div> </div> </div> <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; text-align: center;"> <div> <div style="font-size: 16px; font-weight: bold; color: #3b82f6;">${member.activeIssues}</div> <div style="font-size: 11px; color: #6b7280;">Active</div> </div> <div> <div style="font-size: 16px; font-weight: bold; color: #10b981;">${member.upcomingIssues}</div> <div style="font-size: 11px; color: #6b7280;">Upcoming</div> </div> <div> <div style="font-size: 16px; font-weight: bold; color: ${member.overdueIssues > 0 ? '#ef4444' : '#6b7280'};">${member.overdueIssues}</div> <div style="font-size: 11px; color: #6b7280;">Overdue</div> </div> </div> </div> `; }).join(''); const totalIssues = workloadAnalysis.reduce((sum, m) => sum + m.totalWorkload, 0); const avgWorkload = workloadAnalysis.length > 0 ? (totalIssues / workloadAnalysis.length).toFixed(1) : 0; const overloadedMembers = workloadAnalysis.filter(m => m.totalWorkload > 4).length; return ` <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <style> body { font-family: system-ui, -apple-system, sans-serif; margin: 0; padding: 16px; background: #f8fafc; } .container { max-width: 800px; margin: 0 auto; } .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; text-align: center; border-radius: 12px; margin-bottom: 20px; } .summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 20px; } .summary-card { background: white; padding: 16px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); text-align: center; } .summary-number { font-size: 32px; font-weight: bold; color: #3b82f6; } .summary-label { font-size: 14px; color: #6b7280; margin-top: 4px; } </style> </head> <body> <div class="container"> <div class="header"> <h2 style="margin: 0; font-size: 24px;">📊 Team Workload Analysis</h2> <p style="margin: 8px 0 0 0; opacity: 0.9;">Current capacity and distribution</p> </div> <div class="summary"> <div class="summary-card"> <div class="summary-number">${workloadAnalysis.length}</div> <div class="summary-label">Team Members</div> </div> <div class="summary-card"> <div class="summary-number">${totalIssues}</div> <div class="summary-label">Total Issues</div> </div> <div class="summary-card"> <div class="summary-number">${avgWorkload}</div> <div class="summary-label">Avg per Person</div> </div> <div class="summary-card"> <div class="summary-number" style="color: ${overloadedMembers > 0 ? '#ef4444' : '#10b981'};">${overloadedMembers}</div> <div class="summary-label">Overloaded</div> </div> </div> <div class="analysis-list"> ${analysisCards} </div> </div> <script> new ResizeObserver(entries => { entries.forEach(entry => { window.parent.postMessage({ type: "ui-size-change", payload: { height: entry.contentRect.height + 50 } }, "*"); }); }).observe(document.documentElement); </script> </body> </html> `; } // Create best assignee recommendation UI createBestAssigneeUI(bestAssignee, allMembers) { const recommendationCard = ` <div class="recommendation-card" style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: white; border-radius: 12px; padding: 24px; text-align: center; margin-bottom: 24px;"> <div style="font-size: 48px; margin-bottom: 16px;">🎯</div> <h2 style="margin: 0 0 8px 0; font-size: 24px;">Recommended Assignee</h2> <div style="display: flex; align-items: center; justify-content: center; margin: 16px 0;"> <img src="${bestAssignee.avatar_url}" alt="${bestAssignee.login}" style="width: 60px; height: 60px; border-radius: 50%; margin-right: 16px; border: 3px solid rgba(255,255,255,0.3);"> <div> <h3 style="margin: 0; font-size: 20px;">${bestAssignee.login}</h3> <p style="margin: 4px 0 0 0; opacity: 0.9;">Lightest workload</p> </div> </div> </div> `; const workloadComparison = allMembers.slice(0, 5).map((member, index) => { const isRecommended = member.login === bestAssignee.login; const workloadLevel = member.totalWorkload <= 2 ? 'Light' : member.totalWorkload <= 4 ? 'Moderate' : 'Heavy'; const levelColor = member.totalWorkload <= 2 ? '#10b981' : member.totalWorkload <= 4 ? '#f59e0b' : '#ef4444'; return ` <div class="comparison-card" style="background: ${isRecommended ? '#f0fdf4' : 'white'}; border: 2px solid ${isRecommended ? '#10b981' : '#e5e7eb'}; border-radius: 8px; padding: 16px; margin: 8px 0;"> <div style="display: flex; align-items: center; margin-bottom: 12px;"> ${isRecommended ? '<div style="background: #10b981; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: bold; margin-right: 12px;">✓</div>' : `<div style="background: #f3f4f6; color: #6b7280; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: bold; margin-right: 12px;">${index + 1}</div>`} <img src="${member.avatar_url}" alt="${member.login}" style="width: 32px; height: 32px; border-radius: 50%; margin-right: 12px;"> <div style="flex: 1;"> <h4 style="margin: 0; color: #1f2937;">${member.login}</h4> <span style="background: ${levelColor}; color: white; padding: 1px 6px; border-radius: 8px; font-size: 11px;">${workloadLevel}</span> </div> <div style="text-align: right;"> <div style="font-size: 18px; font-weight: bold; color: ${levelColor};">${member.totalWorkload}</div> <div style="font-size: 10px; color: #6b7280;">issues</div> </div> </div> <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; font-size: 12px; text-align: center;"> <div> <span style="font-weight: bold; color: #3b82f6;">${member.activeIssues}</span> <span style="color: #6b7280;"> active</span> </div> <div> <span style="font-weight: bold; color: #10b981;">${member.upcomingIssues}</span> <span style="color: #6b7280;"> upcoming</span> </div> <div> <span style="font-weight: bold; color: ${member.overdueIssues > 0 ? '#ef4444' : '#6b7280'};">${member.overdueIssues}</span> <span style="color: #6b7280;"> overdue</span> </div> </div> </div> `; }).join(''); return ` <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <style> body { font-family: system-ui, -apple-system, sans-serif; margin: 0; padding: 16px; background: #f8fafc; } .container { max-width: 600px; margin: 0 auto; } </style> </head> <body> <div class="container"> ${recommendationCard} <div style="background: white; border-radius: 12px; padding: 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);"> <h3 style="margin: 0 0 16px 0; color: #1f2937;">Team Workload Comparison</h3> ${workloadComparison} </div> </div> <script> new ResizeObserver(entries => { entries.forEach(entry => { window.parent.postMessage({ type: "ui-size-change", payload: { height: entry.contentRect.height + 50 } }, "*"); }); }).observe(document.documentElement); </script> </body> </html> `; } // Create team status dashboard UI createTeamStatusUI(workloadAnalysis) { const teamCards = workloadAnalysis.map(member => { const workloadLevel = member.totalWorkload <= 2 ? 'Light' : member.totalWorkload <= 4 ? 'Moderate' : 'Heavy'; const levelColor = member.totalWorkload <= 2 ? '#10b981' : member.totalWorkload <= 4 ? '#f59e0b' : '#ef4444'; return ` <div class="team-card" style="background: white; border-radius: 8px; padding: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); margin: 8px;"> <div style="display: flex; align-items: center; margin-bottom: 12px;"> <img src="${member.avatar_url}" alt="${member.login}" style="width: 40px; height: 40px; border-radius: 50%; margin-right: 12px;"> <div> <h3 style="margin: 0; font-size: 16px; color: #1f2937;">${member.login}</h3> <span style="background: ${levelColor}; color: white; padding: 2px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;">${workloadLevel}</span> </div> </div> <div class="workload-stats" style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px;"> <div class="stat"> <div style="font-size: 20px; font-weight: bold; color: #3b82f6;">${member.activeIssues}</div> <div style="font-size: 12px; color: #6b7280;">Active</div> </div> <div class="stat"> <div style="font-size: 20px; font-weight: bold; color: #10b981;">${member.upcomingIssues}</div> <div style="font-size: 12px; color: #6b7280;">Upcoming</div> </div> <div class="stat"> <div style="font-size: 20px; font-weight: bold; color: ${member.overdueIssues > 0 ? '#ef4444' : '#6b7280'};">${member.overdueIssues}</div> <div style="font-size: 12px; color: #6b7280;">Overdue</div> </div> <div class="stat"> <div style="font-size: 20px; font-weight: bold; color: #8b5cf6;">${member.totalWorkload}</div> <div style="font-size: 12px; color: #6b7280;">Total</div> </div> </div> </div> `; }).join(''); return ` <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <style> body { font-family: system-ui, -apple-system, sans-serif; margin: 0; padding: 16px; background: #f8fafc; } .container { max-width: 800px; margin: 0 auto; } .header { text-align: center; margin-bottom: 24px; } .team-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px; } </style> </head> <body> <div class="container"> <div class="header"> <h2 style="color: #1f2937; margin: 0;">👥 Team Status Dashboard</h2> <p style="color: #6b7280; margin: 8px 0 0 0;">Current workload distribution across team members</p> </div> <div class="team-grid"> ${teamCards} </div> </div> <script> // Auto-resize the iframe to fit content new ResizeObserver(entries => { entries.forEach(entry => { window.parent.postMessage({ type: "ui-size-change", payload: { height: entry.contentRect.height + 50 } }, "*"); }); }).observe(document.documentElement); </script> </body> </html> `; } setupToolHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: 'get_team_status', description: 'Get current status of the development team including active issues, due items, and recent completions for each team member', inputSchema: { type: 'object', properties: {}, }, }, { name: 'get_person_schedule', description: 'Get the schedule and upcoming work for a specific team member', inputSchema: { type: 'object', properties: { login: { type: 'string', description: 'GitHub username of the team member', }, days: { type: 'number', description: 'Number of days to look ahead (default: 7)', default: 7, }, }, required: ['login'], }, }, { name: 'analyze_workload', description: 'Analyze team workload distribution and identify who can take on new tasks', inputSchema: { type: 'object', properties: {}, }, }, { name: 'find_best_assignee', description: 'Find the team member with the lightest workload for assigning new tasks', inputSchema: { type: 'object', properties: {}, }, }, { name: 'get_calendar_events', description: 'Get GitHub project calendar events with optional filtering', inputSchema: { type: 'object', properties: { org: { type: 'string', description: 'GitHub organization name (default: squareup)', default: DEFAULT_ORG, }, project: { type: 'number', description: 'GitHub project number (default: 333)', default: DEFAULT_PROJECT_NUMBER, }, since: { type: 'string', description: 'ISO date string to filter events from (default: 2025-08-01)', }, assignee: { type: 'string', description: 'Filter events by assignee GitHub username', }, }, }, }, ], }; }); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { switch (name) { case 'get_team_status': { const events = await this.getCalendarEvents(); const workloadAnalysis = this.analyzeTeamWorkload(events); const statusReport = workloadAnalysis.map(member => { return `**${member.login}**\n` + `- Active Issues: ${member.activeIssues}\n` + `- Upcoming Issues: ${member.upcomingIssues}\n` + `- Overdue Issues: ${member.overdueIssues}\n` + `- Total Workload: ${member.totalWorkload}`; }).join('\n\n'); return { content: [ { type: 'text', text: `# Team Status Report\n\n${statusReport || 'No team members found with active work.'}`, }, createUIResource({ uri: `ui://team-status/${Date.now()}`, content: { type: 'rawHtml', htmlString: this.createTeamStatusUI(workloadAnalysis) }, encoding: 'text' }) ], }; } case 'get_person_schedule': { const { login, days = 7 } = args; const events = await this.getCalendarEvents(); const endDate = addDays(new Date(), days); const personEvents = events.filter(event => event.assignees.some(assignee => assignee.login === login) && event.startDate <= endDate ).sort((a, b) => a.startDate - b.startDate); const scheduleText = personEvents.length > 0 ? personEvents.map(event => { const startStr = format(event.startDate, 'MMM dd, yyyy'); const endStr = event.endDate ? format(event.endDate, 'MMM dd, yyyy') : 'No end date'; return `**${event.title}** (${event.status})\n` + `- Start: ${startStr}\n` + `- End: ${endStr}\n` + `- URL: ${event.url}`; }).join('\n\n') : `No upcoming work found for ${login} in the next ${days} days.`; return { content: [ { type: 'text', text: `# Schedule for ${login} (Next ${days} days)\n\n${scheduleText}`, }, createUIResource({ uri: `ui://person-schedule/${login}/${Date.now()}`, content: { type: 'rawHtml', htmlString: this.createPersonScheduleUI(personEvents, login, days) }, encoding: 'text' }) ], }; } case 'analyze_workload': { const events = await this.getCalendarEvents(); const workloadAnalysis = this.analyzeTeamWorkload(events); const analysisText = workloadAnalysis.map((member, index) => { const workloadLevel = member.totalWorkload <= 2 ? 'Light' : member.totalWorkload <= 4 ? 'Moderate' : 'Heavy'; return `${index + 1}. **${member.login}** - ${workloadLevel} (${member.totalWorkload} issues)\n` + ` - Active: ${member.activeIssues}, Upcoming: ${member.upcomingIssues}, Overdue: ${member.overdueIssues}`; }).join('\n'); return { content: [ { type: 'text', text: `# Team Workload Analysis\n\n${analysisText || 'No team workload data available.'}`, }, createUIResource({ uri: `ui://workload-analysis/${Date.now()}`, content: { type: 'rawHtml', htmlString: this.createWorkloadAnalysisUI(workloadAnalysis) }, encoding: 'text' }) ], }; } case 'find_best_assignee': { const events = await this.getCalendarEvents(); const workloadAnalysis = this.analyzeTeamWorkload(events); if (workloadAnalysis.length === 0) { return { content: [ { type: 'text', text: 'No team members found in the current project.', }, ], }; } const bestAssignee = workloadAnalysis[0]; // Already sorted by workload return { content: [ { type: 'text', text: `# Best Assignee Recommendation\n\n` + `**${bestAssignee.login}** has the lightest workload:\n` + `- Current workload: ${bestAssignee.totalWorkload} issues\n` + `- Active: ${bestAssignee.activeIssues}\n` + `- Upcoming: ${bestAssignee.upcomingIssues}\n` + `- Overdue: ${bestAssignee.overdueIssues}`, }, createUIResource({ uri: `ui://best-assignee/${Date.now()}`, content: { type: 'rawHtml', htmlString: this.createBestAssigneeUI(bestAssignee, workloadAnalysis) }, encoding: 'text' }) ], }; } case 'get_calendar_events': { const { org = DEFAULT_ORG, project = DEFAULT_PROJECT_NUMBER, since, assignee } = args; let sinceDate; if (since) { sinceDate = parseISO(since); } let events = await this.getCalendarEvents(org, project, sinceDate); // Filter by assignee if specified if (assignee) { events = events.filter(event => event.assignees.some(a => a.login === assignee) ); } if (events.length === 0) { return { content: [ { type: 'text', text: 'No calendar events found matching the criteria.', }, createUIResource({ uri: `ui://calendar-empty/${Date.now()}`, content: { type: 'rawHtml', htmlString: this.createCalendarUI([]) }, encoding: 'text' }) ], }; } const eventsText = events.map(event => { const startStr = format(event.startDate, 'MMM dd, yyyy'); const endStr = event.endDate ? format(event.endDate, 'MMM dd, yyyy') : 'No end date'; const assigneeList = event.assignees.map(a => a.login).join(', ') || 'Unassigned'; return `**${event.title}** (${event.status})\n` + `- Assignees: ${assigneeList}\n` + `- Start: ${startStr}\n` + `- End: ${endStr}\n` + `- URL: ${event.url}`; }).join('\n\n'); return { content: [ { type: 'text', text: `# Calendar Events (${events.length} found)\n\n${eventsText}`, }, createUIResource({ uri: `ui://calendar/${Date.now()}`, content: { type: 'rawHtml', htmlString: this.createCalendarUI(events) }, encoding: 'text' }) ], }; } default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { console.error(`Error in tool ${name}:`, error); return { content: [ { type: 'text', text: `Error: ${error.message}`, }, ], isError: true, }; } }); } async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('GitHub Calendar MCP server running on stdio'); } } // Handle graceful shutdown process.on('SIGINT', async () => { await server.close(); process.exit(0); }); const server = new GitHubCalendarMCPServer(); server.run().catch(console.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/blackgirlbytes/github-calendar-mcp-server'

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