list-events
Retrieve upcoming events from your Outlook calendar. Optionally specify the number of events to return, up to 50.
Instructions
Lists upcoming events from your calendar
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| count | No | Number of events to retrieve (default: 10, max: 50) |
Implementation Reference
- calendar/index.js:12-32 (registration)Registration of the 'list-events' tool with its metadata, input schema, and handler reference.
{ name: 'list-events', description: 'Lists upcoming events from your calendar', annotations: { title: 'List Calendar Events', readOnlyHint: true, openWorldHint: false, }, inputSchema: { type: 'object', properties: { count: { type: 'number', description: 'Number of events to retrieve (default: 10, max: 50)', }, }, additionalProperties: false, required: [], }, handler: handleListEvents, }, - calendar/list.js:13-106 (handler)Handler function that calls the Microsoft Graph API (me/events), formats and returns upcoming calendar events.
async function handleListEvents(args) { const count = Math.min(args.count || 10, config.MAX_RESULT_COUNT); try { // Get access token const accessToken = await ensureAuthenticated(); // Build API endpoint const endpoint = 'me/events'; // Add query parameters const queryParams = { $top: count, $orderby: 'start/dateTime', $filter: `start/dateTime ge '${new Date().toISOString()}'`, $select: config.CALENDAR_SELECT_FIELDS, }; // Make API call const response = await callGraphAPI( accessToken, 'GET', endpoint, null, queryParams ); if (!response.value || response.value.length === 0) { return { content: [ { type: 'text', text: 'No calendar events found.', }, ], }; } // Format results const tz = config.DEFAULT_TIMEZONE; const eventList = response.value .map((event, index) => { const startDt = event.start.dateTime.endsWith('Z') ? event.start.dateTime : `${event.start.dateTime}Z`; const endDt = event.end.dateTime.endsWith('Z') ? event.end.dateTime : `${event.end.dateTime}Z`; const startDate = new Date(startDt).toLocaleString('en-AU', { timeZone: tz, dateStyle: 'medium', timeStyle: 'short', }); const endDate = new Date(endDt).toLocaleString('en-AU', { timeZone: tz, dateStyle: 'medium', timeStyle: 'short', }); const location = event.location.displayName || 'No location'; return `${index + 1}. ${event.subject} - Location: ${location}\nStart: ${startDate}\nEnd: ${endDate}\nSummary: ${event.bodyPreview}\nID: ${event.id}\n`; }) .join('\n'); return { content: [ { type: 'text', text: `Found ${response.value.length} events:\n\n${eventList}`, }, ], }; } catch (error) { if (error.message === 'Authentication required') { return { content: [ { type: 'text', text: "Authentication required. Please use the 'authenticate' tool first.", }, ], }; } return { content: [ { type: 'text', text: `Error listing events: ${error.message}`, }, ], }; } } - calendar/index.js:20-30 (schema)Input schema for list-events: optional 'count' parameter (number, default 10, max 50). No required parameters.
inputSchema: { type: 'object', properties: { count: { type: 'number', description: 'Number of events to retrieve (default: 10, max: 50)', }, }, additionalProperties: false, required: [], }, - config.js:67-102 (helper)Configuration constant CALENDAR_SELECT_FIELDS used by the handler to specify which fields to retrieve from the Graph API.
CALENDAR_SELECT_FIELDS: 'id,subject,bodyPreview,start,end,location,organizer,attendees,isAllDay,isCancelled', // Email field presets (use getEmailFields() for dynamic selection) FIELD_PRESETS, getEmailFields, // Legacy email fields (kept for backward compatibility) EMAIL_SELECT_FIELDS: getEmailFields('list'), EMAIL_DETAIL_FIELDS: getEmailFields('read'), EMAIL_FORENSIC_FIELDS: getEmailFields('forensic'), EMAIL_EXPORT_FIELDS: getEmailFields('export'), // Folder field presets FOLDER_FIELDS, getFolderFields, // Verbosity levels for response formatting VERBOSITY, // Default limits for token efficiency DEFAULT_LIMITS, // Pagination (updated to use DEFAULT_LIMITS) DEFAULT_PAGE_SIZE: DEFAULT_LIMITS.listEmails, MAX_RESULT_COUNT: 100, // Increased for batch operations // Search defaults (reduced for token efficiency) DEFAULT_SEARCH_RESULTS: DEFAULT_LIMITS.searchEmails, // Immutable IDs (opt-in: IDs persist through folder moves) USE_IMMUTABLE_IDS: process.env.OUTLOOK_IMMUTABLE_IDS === 'true', // Timezone DEFAULT_TIMEZONE: 'Australia/Melbourne', // Updated for Nathan's timezone }; - utils/graph-api.js:21-399 (helper)The callGraphAPI helper function that makes HTTP requests to the Microsoft Graph API, used by the list-events handler.
async function callGraphAPI( accessToken, method, path, data = null, queryParams = {}, extraHeaders = {} ) { // For test tokens, we'll simulate the API call if (config.USE_TEST_MODE && accessToken.startsWith('test_access_token_')) { return mockData.simulateGraphAPIResponse(method, path, data, queryParams); } try { // Check if path already contains the full URL (from nextLink) let finalUrl; if (path.startsWith('http://') || path.startsWith('https://')) { // Path is already a full URL (from pagination nextLink) finalUrl = path; } else { // Build URL from path and queryParams // Encode path segments properly const encodedPath = path .split('/') .map((segment) => encodeURIComponent(segment)) .join('/'); // Build query string from parameters with special handling for OData filters let queryString = ''; if (Object.keys(queryParams).length > 0) { // Handle $filter parameter specially to ensure proper URI encoding const filter = queryParams.$filter; if (filter) { delete queryParams.$filter; // Remove from regular params } // Build query string with proper encoding for regular params const params = new URLSearchParams(); for (const [key, value] of Object.entries(queryParams)) { params.append(key, value); } queryString = params.toString(); // Add filter parameter separately with proper encoding if (filter) { if (queryString) { queryString += `&$filter=${encodeURIComponent(filter)}`; } else { queryString = `$filter=${encodeURIComponent(filter)}`; } } if (queryString) { queryString = `?${queryString}`; } } finalUrl = `${config.GRAPH_API_ENDPOINT}${encodedPath}${queryString}`; } return new Promise((resolve, reject) => { const headers = { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }; // Add immutable IDs header when enabled globally if (config.USE_IMMUTABLE_IDS) { headers.Prefer = 'IdType="ImmutableId"'; } // Merge any extra headers (caller overrides take precedence) Object.assign(headers, extraHeaders); const options = { method: method, headers, }; const req = https.request(finalUrl, options, (res) => { let responseData = ''; res.on('data', (chunk) => { responseData += chunk; }); res.on('end', () => { if (res.statusCode >= 200 && res.statusCode < 300) { try { responseData = responseData ? responseData : '{}'; const jsonResponse = JSON.parse(responseData); resolve(jsonResponse); } catch (error) { reject(new Error(`Error parsing API response: ${error.message}`)); } } else if (res.statusCode === 401) { // Token expired or invalid reject(new Error('UNAUTHORIZED')); } else { // Truncate response to avoid leaking sensitive data in error messages const safeResponse = responseData.substring(0, 200); reject( new Error( `API call failed with status ${res.statusCode}: ${safeResponse}` ) ); } }); }); req.on('error', (error) => { reject(new Error(`Network error during API call: ${error.message}`)); }); if ( data && (method === 'POST' || method === 'PATCH' || method === 'PUT') ) { req.write(JSON.stringify(data)); } req.end(); }); } catch (error) { console.error('Error calling Graph API:', error); throw error; } } /** * Calls Graph API with pagination support to retrieve all results up to maxCount * @param {string} accessToken - The access token for authentication * @param {string} method - HTTP method (GET only for pagination) * @param {string} path - API endpoint path * @param {object} queryParams - Initial query parameters * @param {number} maxCount - Maximum number of items to retrieve (0 = all) * @returns {Promise<object>} - Combined API response with all items * @throws {Error} If method is not 'GET' * @throws {Error} If any page request fails for any other reason */ async function callGraphAPIPaginated( accessToken, method, path, queryParams = {}, maxCount = 0 ) { if (method !== 'GET') { throw new Error('Pagination only supports GET requests'); } const allItems = []; let nextLink; let currentUrl = path; let currentParams = { ...queryParams }; try { do { // Make API call const response = await callGraphAPI( accessToken, method, currentUrl, null, currentParams ); // Add items from this page if (response.value && Array.isArray(response.value)) { allItems.push(...response.value); } // Check if we've reached the desired count if (maxCount > 0 && allItems.length >= maxCount) { break; } // Get next page URL nextLink = response['@odata.nextLink']; if (nextLink) { // Pass the full nextLink URL directly to callGraphAPI currentUrl = nextLink; currentParams = {}; // nextLink already contains all params } } while (nextLink); // Trim to exact count if needed const finalItems = maxCount > 0 ? allItems.slice(0, maxCount) : allItems; return { value: finalItems, '@odata.count': finalItems.length, }; } catch (error) { console.error('Error during pagination:', error); throw error; } } /** * Sends multiple Graph API requests in a single batch call ($batch). * Supports up to 20 requests per batch (Graph API limit). * @param {string} accessToken - The access token for authentication * @param {Array<{id: string, method: string, url: string, body?: object, headers?: object}>} requests - Batch requests * @returns {Promise<Array<{id: string, status: number, body: object}>>} - Array of responses */ async function callGraphAPIBatch(accessToken, requests) { if (!Array.isArray(requests) || requests.length === 0) { throw new Error('Batch requests must be a non-empty array'); } if (requests.length > 20) { throw new Error('Batch requests cannot exceed 20 (Graph API limit)'); } // Test mode if (config.USE_TEST_MODE && accessToken.startsWith('test_access_token_')) { return requests.map((req) => ({ id: req.id, status: 200, body: mockData.simulateGraphAPIResponse( req.method, req.url, req.body || null, {} ), })); } const batchPayload = { requests: requests.map((req) => ({ id: req.id, method: req.method, url: req.url.startsWith('/') ? req.url : `/${req.url}`, ...(req.body && { body: req.body }), ...(req.headers && { headers: req.headers }), })), }; const response = await callGraphAPI( accessToken, 'POST', '$batch', batchPayload ); return (response.responses || []).sort( (a, b) => parseInt(a.id) - parseInt(b.id) ); } /** * Calls Graph API to get raw MIME content (for email export) * In test mode (USE_TEST_MODE=true), returns mock MIME content instead of calling the real API. * @param {string} accessToken - The access token for authentication * @param {string} emailId - The email ID to export * @returns {Promise<string>} - Raw MIME content as string * @throws {Error} 'UNAUTHORIZED' if the server returns HTTP 401 (token expired or invalid) * @throws {Error} If the HTTP status is outside 2xx or a network error occurs */ async function callGraphAPIRaw(accessToken, emailId) { // Test mode: return mock MIME content if (config.USE_TEST_MODE && accessToken.startsWith('test_access_token_')) { return mockData.getMockMimeContent ? mockData.getMockMimeContent(emailId) : `MIME-Version: 1.0\nContent-Type: text/plain\n\nTest email content for ${emailId}`; } return new Promise((resolve, reject) => { const path = `me/messages/${encodeURIComponent(emailId)}/$value`; const finalUrl = `${config.GRAPH_API_ENDPOINT}${path}`; const options = { method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, Accept: 'message/rfc822', // Request MIME format }, }; const req = https.request(finalUrl, options, (res) => { let responseData = ''; // Collect data as UTF-8 string res.setEncoding('utf8'); res.on('data', (chunk) => { responseData += chunk; }); res.on('end', () => { if (res.statusCode >= 200 && res.statusCode < 300) { resolve(responseData); } else if (res.statusCode === 401) { reject(new Error('UNAUTHORIZED')); } else { reject( new Error( `MIME export failed with status ${res.statusCode}: ${responseData.substring(0, 200)}` ) ); } }); }); req.on('error', (error) => { reject(new Error(`Network error during MIME export: ${error.message}`)); }); req.end(); }); } /** * Calls Graph API with automatic auth and 401 retry. * Gets token via ensureAuthenticated(), and if a 401 occurs, * refreshes the token and retries once. * @param {string} method - HTTP method * @param {string} path - API endpoint path * @param {object} data - Request body * @param {object} queryParams - Query parameters * @param {object} extraHeaders - Additional headers * @returns {Promise<object>} - API response */ async function callGraphAPIWithAuth( method, path, data = null, queryParams = {}, extraHeaders = {} ) { // Lazy require to avoid circular dependency const { ensureAuthenticated, tokenStorage } = require('../auth'); const accessToken = await ensureAuthenticated(); try { return await callGraphAPI( accessToken, method, path, data, queryParams, extraHeaders ); } catch (error) { if (error.message === 'UNAUTHORIZED' && tokenStorage) { console.error('[GRAPH-API] 401 received, attempting token refresh...'); try { const newToken = await tokenStorage.refreshAccessToken(); if (newToken) { return await callGraphAPI( newToken, method, path, data, queryParams, extraHeaders ); } } catch (refreshError) { console.error( '[GRAPH-API] Token refresh failed:', refreshError.message ); } } throw error; } } module.exports = { callGraphAPI, callGraphAPIPaginated, callGraphAPIBatch, callGraphAPIRaw, callGraphAPIWithAuth, };