From a37eeb3eceeabd5c2c093df67c6f4f3665d6597e Mon Sep 17 00:00:00 2001 From: Manik Date: Wed, 11 Mar 2026 22:02:49 +0530 Subject: [PATCH 1/2] feat: add Microsoft Outlook integration via Graph API Implements OAuth2 integration with Microsoft Graph API for mail, calendar, and contacts. Includes 18 MCP tools covering email CRUD, calendar events, and contact management. Syncs inbox, sent items, and calendar on 15-min schedule. Closes #66 Co-Authored-By: Claude Opus 4.6 --- integrations/outlook/.prettierrc | 19 + integrations/outlook/eslint.config.js | 72 ++ integrations/outlook/package.json | 60 ++ integrations/outlook/src/account-create.ts | 55 ++ integrations/outlook/src/index.ts | 112 +++ .../outlook/src/mcp/generated-tools.ts | 14 + integrations/outlook/src/mcp/index.ts | 718 ++++++++++++++++++ integrations/outlook/src/schedule.ts | 274 +++++++ integrations/outlook/src/utils.ts | 88 +++ integrations/outlook/tsconfig.json | 34 + 10 files changed, 1446 insertions(+) create mode 100644 integrations/outlook/.prettierrc create mode 100644 integrations/outlook/eslint.config.js create mode 100644 integrations/outlook/package.json create mode 100644 integrations/outlook/src/account-create.ts create mode 100644 integrations/outlook/src/index.ts create mode 100644 integrations/outlook/src/mcp/generated-tools.ts create mode 100644 integrations/outlook/src/mcp/index.ts create mode 100644 integrations/outlook/src/schedule.ts create mode 100644 integrations/outlook/src/utils.ts create mode 100644 integrations/outlook/tsconfig.json diff --git a/integrations/outlook/.prettierrc b/integrations/outlook/.prettierrc new file mode 100644 index 00000000..83332558 --- /dev/null +++ b/integrations/outlook/.prettierrc @@ -0,0 +1,19 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true, + "quoteProps": "as-needed", + "trailingComma": "es5", + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "avoid", + "rangeStart": 0, + "rangeEnd": null, + "requirePragma": false, + "insertPragma": false, + "proseWrap": "preserve", + "htmlWhitespaceSensitivity": "css", + "endOfLine": "lf" +} diff --git a/integrations/outlook/eslint.config.js b/integrations/outlook/eslint.config.js new file mode 100644 index 00000000..0f27a212 --- /dev/null +++ b/integrations/outlook/eslint.config.js @@ -0,0 +1,72 @@ +import js from '@eslint/js'; +import eslintConfigPrettier from 'eslint-config-prettier'; +import importPlugin from 'eslint-plugin-import'; +import eslintPluginPrettier from 'eslint-plugin-prettier'; +import unusedImports from 'eslint-plugin-unused-imports'; +import typescriptEslint from 'typescript-eslint'; + +export default [ + js.configs.recommended, + ...typescriptEslint.configs.recommended, + eslintConfigPrettier, + { + files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'], + languageOptions: { + ecmaVersion: 2020, + sourceType: 'module', + globals: { + console: 'readonly', + process: 'readonly', + Buffer: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + global: 'readonly', + }, + }, + plugins: { + import: importPlugin, + prettier: eslintPluginPrettier, + 'unused-imports': unusedImports, + }, + rules: { + 'prettier/prettier': 'error', + 'no-unused-vars': 'off', + 'unused-imports/no-unused-imports': 'error', + 'unused-imports/no-unused-vars': [ + 'warn', + { + vars: 'all', + varsIgnorePattern: '^_', + args: 'after-used', + argsIgnorePattern: '^_', + }, + ], + 'import/order': [ + 'error', + { + groups: [ + 'builtin', + 'external', + 'internal', + 'parent', + 'sibling', + 'index', + ], + 'newlines-between': 'always', + }, + ], + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-non-null-assertion': 'warn', + }, + settings: { + 'import/resolver': { + node: { + extensions: ['.js', '.jsx', '.ts', '.tsx'], + }, + }, + }, + }, +]; diff --git a/integrations/outlook/package.json b/integrations/outlook/package.json new file mode 100644 index 00000000..240799f8 --- /dev/null +++ b/integrations/outlook/package.json @@ -0,0 +1,60 @@ +{ + "name": "@core/outlook", + "version": "0.1.0", + "description": "Microsoft Outlook integration for Core", + "main": "./bin/index.js", + "module": "./bin/index.mjs", + "type": "module", + "files": [ + "outlook", + "bin" + ], + "bin": { + "outlook": "./bin/index.js" + }, + "scripts": { + "build": "rimraf dist && bun build src/index.ts --outfile dist/index.js --target node --minify", + "lint": "eslint --ext js,ts,tsx src/ --fix", + "prettier": "prettier --config .prettierrc --write ." + }, + "devDependencies": { + "@babel/preset-typescript": "^7.26.0", + "@types/node": "^18.0.20", + "@types/turndown": "^5.0.5", + "eslint": "^9.24.0", + "eslint-config-prettier": "^10.1.2", + "eslint-import-resolver-alias": "^1.1.2", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jest": "^27.9.0", + "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-unused-imports": "^2.0.0", + "prettier": "^3.4.2", + "rimraf": "^3.0.2", + "tslib": "^2.8.1", + "typescript": "^4.7.2", + "@modelcontextprotocol/sdk": "^0.4.0", + "zod": "^3.22.4", + "zod-to-json-schema": "^3.22.1" + }, + "publishConfig": { + "access": "public" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "dependencies": { + "axios": "^1.7.9", + "commander": "^12.0.0", + "@redplanethq/sdk": "0.1.9", + "turndown": "^7.2.0" + } +} diff --git a/integrations/outlook/src/account-create.ts b/integrations/outlook/src/account-create.ts new file mode 100644 index 00000000..43ac89b0 --- /dev/null +++ b/integrations/outlook/src/account-create.ts @@ -0,0 +1,55 @@ +import axios from 'axios'; + +export async function integrationCreate( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: any +) { + const { oauthResponse, oauthParams } = data; + + // Fetch user information from Microsoft Graph + let userEmail = null; + let userId = null; + let displayName = null; + + try { + const userInfoResponse = await axios.get('https://graph.microsoft.com/v1.0/me', { + headers: { + Authorization: `Bearer ${oauthResponse.access_token}`, + }, + }); + + userEmail = userInfoResponse.data.mail || userInfoResponse.data.userPrincipalName; + userId = userInfoResponse.data.id; + displayName = userInfoResponse.data.displayName; + } catch (error) { + console.error('Error fetching user info:', error); + } + + const integrationConfiguration = { + access_token: oauthResponse.access_token, + refresh_token: oauthResponse.refresh_token, + client_id: oauthResponse.client_id, + client_secret: oauthResponse.client_secret, + token_type: oauthResponse.token_type, + expires_in: oauthResponse.expires_in, + expires_at: oauthResponse.expires_at, + scope: oauthResponse.scope, + userEmail: userEmail, + userId: userId, + displayName: displayName, + redirect_uri: oauthParams.redirect_uri || null, + }; + + const payload = { + settings: {}, + accountId: integrationConfiguration.userEmail || integrationConfiguration.userId, + config: integrationConfiguration, + }; + + return [ + { + type: 'account', + data: payload, + }, + ]; +} diff --git a/integrations/outlook/src/index.ts b/integrations/outlook/src/index.ts new file mode 100644 index 00000000..7649b88a --- /dev/null +++ b/integrations/outlook/src/index.ts @@ -0,0 +1,112 @@ +import { + IntegrationCLI, + IntegrationEventPayload, + IntegrationEventType, + Spec, +} from '@redplanethq/sdk'; + +import { integrationCreate } from './account-create'; +import { handleSchedule } from './schedule'; +import { getTools, callTool } from './mcp'; +import { fileURLToPath } from 'url'; + +export async function run(eventPayload: IntegrationEventPayload) { + switch (eventPayload.event) { + case IntegrationEventType.SETUP: + return await integrationCreate(eventPayload.eventBody); + + case IntegrationEventType.SYNC: + return await handleSchedule(eventPayload.config, eventPayload.state); + + case IntegrationEventType.GET_TOOLS: { + const tools = await getTools(); + + return tools; + } + + case IntegrationEventType.CALL_TOOL: { + const integrationDefinition = eventPayload.integrationDefinition; + + if (!integrationDefinition) { + return null; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const config = eventPayload.config as any; + const { name, arguments: args } = eventPayload.eventBody; + + const result = await callTool( + name, + args, + integrationDefinition.config.clientId, + integrationDefinition.config.clientSecret, + config?.redirect_uri, + config + ); + + return result; + } + + default: + return { message: `The event payload type is ${eventPayload.event}` }; + } +} + +class OutlookCLI extends IntegrationCLI { + constructor() { + super('outlook', '1.0.0'); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + protected async handleEvent(eventPayload: IntegrationEventPayload): Promise { + return await run(eventPayload); + } + + protected async getSpec(): Promise { + return { + name: 'Outlook extension', + key: 'outlook', + description: + 'Connect your workspace to Microsoft Outlook. Monitor emails, manage calendar events, and handle contacts via Microsoft Graph API', + icon: 'outlook', + mcp: { + type: 'cli', + }, + schedule: { + frequency: '*/15 * * * *', + }, + auth: { + OAuth2: { + token_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + authorization_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + scopes: [ + 'openid', + 'profile', + 'email', + 'offline_access', + 'https://graph.microsoft.com/Mail.ReadWrite', + 'https://graph.microsoft.com/Mail.Send', + 'https://graph.microsoft.com/Calendars.ReadWrite', + 'https://graph.microsoft.com/Contacts.ReadWrite', + 'https://graph.microsoft.com/User.Read', + ], + scope_identifier: 'scope', + scope_separator: ' ', + token_params: {}, + authorization_params: { + response_type: 'code', + }, + }, + }, + } as any; + } +} + +function main() { + const outlookCLI = new OutlookCLI(); + outlookCLI.parse(); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/integrations/outlook/src/mcp/generated-tools.ts b/integrations/outlook/src/mcp/generated-tools.ts new file mode 100644 index 00000000..c5d245df --- /dev/null +++ b/integrations/outlook/src/mcp/generated-tools.ts @@ -0,0 +1,14 @@ +// Auto-generated tool stubs for Microsoft Outlook (Graph API) +// Custom tools are defined in index.ts + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const generatedTools: any[] = []; + +export async function handleGeneratedTool( + name: string, + _args: Record, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + _client: any +): Promise<{ content: Array<{ type: string; text: string }> }> { + throw new Error(`Unknown generated tool: ${name}`); +} diff --git a/integrations/outlook/src/mcp/index.ts b/integrations/outlook/src/mcp/index.ts new file mode 100644 index 00000000..919c8768 --- /dev/null +++ b/integrations/outlook/src/mcp/index.ts @@ -0,0 +1,718 @@ +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; +import axios, { AxiosInstance } from 'axios'; + +import { generatedTools, handleGeneratedTool } from './generated-tools'; + +const GRAPH_BASE_URL = 'https://graph.microsoft.com/v1.0'; + +let graphClient: AxiosInstance; + +async function loadCredentials( + clientId: string, + clientSecret: string, + _redirectUri: string, + config: Record +) { + let accessToken = config.access_token; + + // Try refreshing the token + try { + const response = await axios.post( + 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: config.refresh_token, + grant_type: 'refresh_token', + scope: 'https://graph.microsoft.com/.default offline_access', + }), + { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } } + ); + accessToken = response.data.access_token; + } catch { + // Use existing token if refresh fails + } + + graphClient = axios.create({ + baseURL: GRAPH_BASE_URL, + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }); +} + +// ─── Schema Definitions ─── + +const SearchEmailsSchema = z.object({ + query: z.string().describe('Search query for emails (uses Microsoft Search syntax)'), + top: z.number().optional().default(25).describe('Number of results to return (max 50)'), + folder: z + .string() + .optional() + .describe('Folder to search in (e.g. inbox, sentitems, drafts). Defaults to all folders'), +}); + +const ReadEmailSchema = z.object({ + messageId: z.string().describe('The ID of the email message to read'), +}); + +const SendEmailSchema = z.object({ + to: z.array(z.string()).describe('Array of recipient email addresses'), + subject: z.string().describe('Email subject'), + body: z.string().describe('Email body content (supports HTML)'), + cc: z.array(z.string()).optional().describe('Array of CC email addresses'), + bcc: z.array(z.string()).optional().describe('Array of BCC email addresses'), + isHtml: z.boolean().optional().default(true).describe('Whether the body content is HTML'), +}); + +const ReplyToEmailSchema = z.object({ + messageId: z.string().describe('The ID of the email to reply to'), + body: z.string().describe('Reply body content (supports HTML)'), + replyAll: z.boolean().optional().default(false).describe('Whether to reply all'), +}); + +const ForwardEmailSchema = z.object({ + messageId: z.string().describe('The ID of the email to forward'), + to: z.array(z.string()).describe('Array of recipient email addresses'), + comment: z.string().optional().describe('Optional comment to include with forwarded email'), +}); + +const CreateDraftSchema = z.object({ + to: z.array(z.string()).describe('Array of recipient email addresses'), + subject: z.string().describe('Email subject'), + body: z.string().describe('Email body content (supports HTML)'), + cc: z.array(z.string()).optional().describe('Array of CC email addresses'), + isHtml: z.boolean().optional().default(true).describe('Whether the body content is HTML'), +}); + +const MoveEmailSchema = z.object({ + messageId: z.string().describe('The ID of the email to move'), + destinationFolder: z + .string() + .describe( + 'Destination folder ID or well-known name (inbox, drafts, sentitems, deleteditems, archive, junkemail)' + ), +}); + +const ListFoldersSchema = z.object({ + top: z.number().optional().default(25).describe('Number of folders to return'), +}); + +const ListEventsSchema = z.object({ + startDateTime: z.string().describe('Start date/time in ISO 8601 format'), + endDateTime: z.string().describe('End date/time in ISO 8601 format'), + top: z.number().optional().default(25).describe('Number of events to return'), +}); + +const CreateEventSchema = z.object({ + subject: z.string().describe('Event subject/title'), + start: z.string().describe('Start date/time in ISO 8601 format'), + end: z.string().describe('End date/time in ISO 8601 format'), + timeZone: z.string().optional().default('UTC').describe('Time zone for the event'), + location: z.string().optional().describe('Event location'), + body: z.string().optional().describe('Event description/body (supports HTML)'), + attendees: z.array(z.string()).optional().describe('Array of attendee email addresses'), + isOnlineMeeting: z.boolean().optional().default(false).describe('Create as online meeting'), +}); + +const UpdateEventSchema = z.object({ + eventId: z.string().describe('The ID of the event to update'), + subject: z.string().optional().describe('New event subject/title'), + start: z.string().optional().describe('New start date/time in ISO 8601 format'), + end: z.string().optional().describe('New end date/time in ISO 8601 format'), + timeZone: z.string().optional().describe('Time zone for the event'), + location: z.string().optional().describe('New event location'), + body: z.string().optional().describe('New event description/body'), +}); + +const DeleteEventSchema = z.object({ + eventId: z.string().describe('The ID of the event to delete'), +}); + +const GetEventSchema = z.object({ + eventId: z.string().describe('The ID of the event to get'), +}); + +const RespondToEventSchema = z.object({ + eventId: z.string().describe('The ID of the event to respond to'), + response: z + .enum(['accept', 'tentativelyAccept', 'decline']) + .describe('Response to the event invitation'), + comment: z.string().optional().describe('Optional comment with the response'), +}); + +const ListContactsSchema = z.object({ + top: z.number().optional().default(25).describe('Number of contacts to return'), + search: z.string().optional().describe('Search query to filter contacts'), +}); + +const CreateContactSchema = z.object({ + givenName: z.string().describe('First name'), + surname: z.string().optional().describe('Last name'), + emailAddresses: z + .array(z.object({ address: z.string(), name: z.string().optional() })) + .optional() + .describe('Email addresses'), + businessPhones: z.array(z.string()).optional().describe('Business phone numbers'), + mobilePhone: z.string().optional().describe('Mobile phone number'), + companyName: z.string().optional().describe('Company name'), + jobTitle: z.string().optional().describe('Job title'), +}); + +const DeleteContactSchema = z.object({ + contactId: z.string().describe('The ID of the contact to delete'), +}); + +const DeleteEmailSchema = z.object({ + messageId: z.string().describe('The ID of the email to delete'), +}); + +// ─── Tool Definitions ─── + +export async function getTools() { + return [ + // Mail tools + { + name: 'outlook_search_emails', + description: + 'Search for emails in Outlook using Microsoft Search syntax. Supports queries like "from:user@example.com", "subject:meeting", or free-text search.', + inputSchema: zodToJsonSchema(SearchEmailsSchema), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + }, + { + name: 'outlook_read_email', + description: 'Read the full content of a specific email by its ID.', + inputSchema: zodToJsonSchema(ReadEmailSchema), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + }, + { + name: 'outlook_send_email', + description: 'Send a new email from the connected Outlook account.', + inputSchema: zodToJsonSchema(SendEmailSchema), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, + }, + { + name: 'outlook_reply_to_email', + description: 'Reply to an existing email. Supports reply and reply-all.', + inputSchema: zodToJsonSchema(ReplyToEmailSchema), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, + }, + { + name: 'outlook_forward_email', + description: 'Forward an existing email to new recipients.', + inputSchema: zodToJsonSchema(ForwardEmailSchema), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, + }, + { + name: 'outlook_create_draft', + description: 'Create a draft email in the Drafts folder.', + inputSchema: zodToJsonSchema(CreateDraftSchema), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, + }, + { + name: 'outlook_move_email', + description: + 'Move an email to a different folder (inbox, drafts, sentitems, deleteditems, archive, junkemail).', + inputSchema: zodToJsonSchema(MoveEmailSchema), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, + }, + { + name: 'outlook_delete_email', + description: 'Delete an email (moves to deleted items).', + inputSchema: zodToJsonSchema(DeleteEmailSchema), + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }, + }, + { + name: 'outlook_list_folders', + description: 'List all mail folders in the Outlook account.', + inputSchema: zodToJsonSchema(ListFoldersSchema), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + }, + + // Calendar tools + { + name: 'outlook_list_events', + description: + 'List calendar events within a date range. Returns events from the primary calendar.', + inputSchema: zodToJsonSchema(ListEventsSchema), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + }, + { + name: 'outlook_get_event', + description: 'Get full details of a specific calendar event.', + inputSchema: zodToJsonSchema(GetEventSchema), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + }, + { + name: 'outlook_create_event', + description: + 'Create a new calendar event. Supports setting attendees, location, online meetings, and more.', + inputSchema: zodToJsonSchema(CreateEventSchema), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, + }, + { + name: 'outlook_update_event', + description: 'Update an existing calendar event.', + inputSchema: zodToJsonSchema(UpdateEventSchema), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, + }, + { + name: 'outlook_delete_event', + description: 'Delete a calendar event.', + inputSchema: zodToJsonSchema(DeleteEventSchema), + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }, + }, + { + name: 'outlook_respond_to_event', + description: 'Accept, tentatively accept, or decline a calendar event invitation.', + inputSchema: zodToJsonSchema(RespondToEventSchema), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, + }, + + // Contacts tools + { + name: 'outlook_list_contacts', + description: 'List contacts from the Outlook address book. Supports search filtering.', + inputSchema: zodToJsonSchema(ListContactsSchema), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + }, + { + name: 'outlook_create_contact', + description: 'Create a new contact in the Outlook address book.', + inputSchema: zodToJsonSchema(CreateContactSchema), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, + }, + { + name: 'outlook_delete_contact', + description: 'Delete a contact from the Outlook address book.', + inputSchema: zodToJsonSchema(DeleteContactSchema), + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }, + }, + + ...generatedTools, + ]; +} + +// ─── Tool Implementations ─── + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function formatResponse(data: any): string { + return JSON.stringify(data, null, 2); +} + +function toRecipients(emails: string[]) { + return emails.map(email => ({ + emailAddress: { address: email }, + })); +} + +export async function callTool( + name: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + args: Record, + clientId: string, + clientSecret: string, + redirectUri: string, + credentials: Record +) { + await loadCredentials(clientId, clientSecret, redirectUri, credentials); + + try { + switch (name) { + // ─── Mail ─── + + case 'outlook_search_emails': { + const { query, top, folder } = SearchEmailsSchema.parse(args); + const basePath = folder ? `/me/mailFolders/${folder}/messages` : '/me/messages'; + const response = await graphClient.get(basePath, { + params: { + $search: `"${query}"`, + $top: top, + $select: 'id,subject,from,receivedDateTime,bodyPreview,isRead,importance,webLink', + $orderby: 'receivedDateTime desc', + }, + }); + + const emails = (response.data.value || []).map( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (msg: any) => ({ + id: msg.id, + subject: msg.subject, + from: msg.from?.emailAddress?.address, + fromName: msg.from?.emailAddress?.name, + receivedDateTime: msg.receivedDateTime, + preview: msg.bodyPreview, + isRead: msg.isRead, + importance: msg.importance, + webLink: msg.webLink, + }) + ); + + return { + content: [{ type: 'text', text: formatResponse(emails) }], + }; + } + + case 'outlook_read_email': { + const { messageId } = ReadEmailSchema.parse(args); + const response = await graphClient.get(`/me/messages/${messageId}`, { + params: { + $select: + 'id,subject,from,toRecipients,ccRecipients,body,receivedDateTime,hasAttachments,importance,webLink', + }, + }); + + const msg = response.data; + return { + content: [ + { + type: 'text', + text: formatResponse({ + id: msg.id, + subject: msg.subject, + from: msg.from?.emailAddress, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + to: msg.toRecipients?.map((r: any) => r.emailAddress), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + cc: msg.ccRecipients?.map((r: any) => r.emailAddress), + body: msg.body?.content, + bodyType: msg.body?.contentType, + receivedDateTime: msg.receivedDateTime, + hasAttachments: msg.hasAttachments, + importance: msg.importance, + webLink: msg.webLink, + }), + }, + ], + }; + } + + case 'outlook_send_email': { + const { to, subject, body, cc, bcc, isHtml } = SendEmailSchema.parse(args); + await graphClient.post('/me/sendMail', { + message: { + subject, + body: { contentType: isHtml ? 'html' : 'text', content: body }, + toRecipients: toRecipients(to), + ccRecipients: cc ? toRecipients(cc) : undefined, + bccRecipients: bcc ? toRecipients(bcc) : undefined, + }, + }); + + return { + content: [{ type: 'text', text: `Email sent successfully to ${to.join(', ')}` }], + }; + } + + case 'outlook_reply_to_email': { + const { messageId, body, replyAll } = ReplyToEmailSchema.parse(args); + const endpoint = replyAll + ? `/me/messages/${messageId}/replyAll` + : `/me/messages/${messageId}/reply`; + + await graphClient.post(endpoint, { comment: body }); + + return { + content: [ + { type: 'text', text: `Reply sent successfully${replyAll ? ' (reply all)' : ''}` }, + ], + }; + } + + case 'outlook_forward_email': { + const { messageId, to, comment } = ForwardEmailSchema.parse(args); + await graphClient.post(`/me/messages/${messageId}/forward`, { + comment: comment || '', + toRecipients: toRecipients(to), + }); + + return { + content: [ + { type: 'text', text: `Email forwarded successfully to ${to.join(', ')}` }, + ], + }; + } + + case 'outlook_create_draft': { + const { to, subject, body, cc, isHtml } = CreateDraftSchema.parse(args); + const response = await graphClient.post('/me/messages', { + subject, + body: { contentType: isHtml ? 'html' : 'text', content: body }, + toRecipients: toRecipients(to), + ccRecipients: cc ? toRecipients(cc) : undefined, + }); + + return { + content: [ + { type: 'text', text: `Draft created successfully. ID: ${response.data.id}` }, + ], + }; + } + + case 'outlook_move_email': { + const { messageId, destinationFolder } = MoveEmailSchema.parse(args); + const response = await graphClient.post(`/me/messages/${messageId}/move`, { + destinationId: destinationFolder, + }); + + return { + content: [ + { type: 'text', text: `Email moved successfully. New ID: ${response.data.id}` }, + ], + }; + } + + case 'outlook_delete_email': { + const { messageId } = DeleteEmailSchema.parse(args); + await graphClient.delete(`/me/messages/${messageId}`); + + return { + content: [{ type: 'text', text: 'Email deleted successfully' }], + }; + } + + case 'outlook_list_folders': { + const { top } = ListFoldersSchema.parse(args); + const response = await graphClient.get('/me/mailFolders', { + params: { + $top: top, + $select: 'id,displayName,totalItemCount,unreadItemCount', + }, + }); + + const folders = (response.data.value || []).map( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (f: any) => ({ + id: f.id, + name: f.displayName, + totalItems: f.totalItemCount, + unreadItems: f.unreadItemCount, + }) + ); + + return { + content: [{ type: 'text', text: formatResponse(folders) }], + }; + } + + // ─── Calendar ─── + + case 'outlook_list_events': { + const { startDateTime, endDateTime, top } = ListEventsSchema.parse(args); + const response = await graphClient.get('/me/calendarView', { + params: { + startDateTime, + endDateTime, + $top: top, + $select: + 'id,subject,start,end,location,organizer,attendees,isAllDay,webLink,bodyPreview', + $orderby: 'start/dateTime asc', + }, + }); + + const events = (response.data.value || []).map( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (e: any) => ({ + id: e.id, + subject: e.subject, + start: e.start, + end: e.end, + location: e.location?.displayName, + organizer: e.organizer?.emailAddress, + attendeeCount: e.attendees?.length || 0, + isAllDay: e.isAllDay, + preview: e.bodyPreview, + webLink: e.webLink, + }) + ); + + return { + content: [{ type: 'text', text: formatResponse(events) }], + }; + } + + case 'outlook_get_event': { + const { eventId } = GetEventSchema.parse(args); + const response = await graphClient.get(`/me/events/${eventId}`, { + params: { + $select: + 'id,subject,start,end,location,organizer,attendees,body,isAllDay,webLink,recurrence,onlineMeeting', + }, + }); + + const e = response.data; + return { + content: [ + { + type: 'text', + text: formatResponse({ + id: e.id, + subject: e.subject, + start: e.start, + end: e.end, + location: e.location?.displayName, + organizer: e.organizer?.emailAddress, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + attendees: e.attendees?.map((a: any) => ({ + email: a.emailAddress, + status: a.status?.response, + })), + body: e.body?.content, + isAllDay: e.isAllDay, + recurrence: e.recurrence, + onlineMeetingUrl: e.onlineMeeting?.joinUrl, + webLink: e.webLink, + }), + }, + ], + }; + } + + case 'outlook_create_event': { + const { subject, start, end, timeZone, location, body, attendees, isOnlineMeeting } = + CreateEventSchema.parse(args); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const eventPayload: any = { + subject, + start: { dateTime: start, timeZone }, + end: { dateTime: end, timeZone }, + isOnlineMeeting, + }; + + if (location) eventPayload.location = { displayName: location }; + if (body) eventPayload.body = { contentType: 'html', content: body }; + if (attendees) { + eventPayload.attendees = attendees.map((email: string) => ({ + emailAddress: { address: email }, + type: 'required', + })); + } + + const response = await graphClient.post('/me/events', eventPayload); + + return { + content: [ + { + type: 'text', + text: `Event created: "${subject}" (ID: ${response.data.id})${response.data.webLink ? `\nLink: ${response.data.webLink}` : ''}`, + }, + ], + }; + } + + case 'outlook_update_event': { + const { eventId, subject, start, end, timeZone, location, body } = + UpdateEventSchema.parse(args); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const updatePayload: any = {}; + if (subject) updatePayload.subject = subject; + if (start) updatePayload.start = { dateTime: start, timeZone: timeZone || 'UTC' }; + if (end) updatePayload.end = { dateTime: end, timeZone: timeZone || 'UTC' }; + if (location) updatePayload.location = { displayName: location }; + if (body) updatePayload.body = { contentType: 'html', content: body }; + + await graphClient.patch(`/me/events/${eventId}`, updatePayload); + + return { + content: [{ type: 'text', text: `Event ${eventId} updated successfully` }], + }; + } + + case 'outlook_delete_event': { + const { eventId } = DeleteEventSchema.parse(args); + await graphClient.delete(`/me/events/${eventId}`); + + return { + content: [{ type: 'text', text: `Event ${eventId} deleted successfully` }], + }; + } + + case 'outlook_respond_to_event': { + const { eventId, response, comment } = RespondToEventSchema.parse(args); + await graphClient.post(`/me/events/${eventId}/${response}`, { + comment: comment || '', + sendResponse: true, + }); + + return { + content: [ + { type: 'text', text: `Event ${eventId}: responded with "${response}"` }, + ], + }; + } + + // ─── Contacts ─── + + case 'outlook_list_contacts': { + const { top, search } = ListContactsSchema.parse(args); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const params: any = { + $top: top, + $select: + 'id,displayName,givenName,surname,emailAddresses,businessPhones,mobilePhone,companyName,jobTitle', + }; + if (search) params.$search = `"${search}"`; + + const response = await graphClient.get('/me/contacts', { params }); + + const contacts = (response.data.value || []).map( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (c: any) => ({ + id: c.id, + displayName: c.displayName, + givenName: c.givenName, + surname: c.surname, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + emails: c.emailAddresses?.map((e: any) => e.address), + businessPhones: c.businessPhones, + mobilePhone: c.mobilePhone, + company: c.companyName, + jobTitle: c.jobTitle, + }) + ); + + return { + content: [{ type: 'text', text: formatResponse(contacts) }], + }; + } + + case 'outlook_create_contact': { + const validated = CreateContactSchema.parse(args); + const response = await graphClient.post('/me/contacts', validated); + + return { + content: [ + { + type: 'text', + text: `Contact created: ${response.data.displayName} (ID: ${response.data.id})`, + }, + ], + }; + } + + case 'outlook_delete_contact': { + const { contactId } = DeleteContactSchema.parse(args); + await graphClient.delete(`/me/contacts/${contactId}`); + + return { + content: [{ type: 'text', text: `Contact ${contactId} deleted successfully` }], + }; + } + + default: { + return await handleGeneratedTool(name, args, graphClient); + } + } + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const err = error as any; + const message = err.response?.data?.error?.message || err.message || 'Unknown error'; + return { + content: [{ type: 'text', text: `Error: ${message}` }], + }; + } +} diff --git a/integrations/outlook/src/schedule.ts b/integrations/outlook/src/schedule.ts new file mode 100644 index 00000000..9856e829 --- /dev/null +++ b/integrations/outlook/src/schedule.ts @@ -0,0 +1,274 @@ +import { getGraphClient, OutlookConfig, formatEmailSender, parseEmailBody } from './utils'; +import TurndownService from 'turndown'; + +interface OutlookSettings { + lastSyncTime?: string; + lastUserEventTime?: string; + emailAddress?: string; +} + +interface ActivityCreateParams { + text: string; + sourceURL: string; +} + +function createActivityMessage(params: ActivityCreateParams) { + return { + type: 'activity', + data: { + text: params.text, + sourceURL: params.sourceURL, + }, + }; +} + +function getDefaultSyncTime(): string { + return new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); +} + +const turndownService = new TurndownService({ + headingStyle: 'atx', + codeBlockStyle: 'fenced', + emDelimiter: '*', +}); + +turndownService.remove(['style', 'script', 'noscript', 'iframe', 'object', 'embed']); + +function cleanEmailContent(body: { contentType: string; content: string }): string { + const content = parseEmailBody(body); + if (!content) return ''; + + if (body.contentType === 'html') { + const markdown = turndownService.turndown(content); + return markdown + .replace(/\n\n+/g, '\n\n') + .replace(/\s+/g, ' ') + .trim(); + } + + return content + .replace(/\r/g, '') + .replace(/\n\n+/g, '\n\n') + .replace(/\s+/g, ' ') + .trim(); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function processReceivedEmails(client: any, lastSyncTime: string): Promise { + const activities = []; + + try { + const response = await client.get('/me/mailFolders/inbox/messages', { + params: { + $filter: `receivedDateTime ge ${lastSyncTime}`, + $orderby: 'receivedDateTime desc', + $top: 50, + $select: 'id,subject,from,receivedDateTime,body,webLink,isRead,importance', + }, + }); + + const messages = response.data.value || []; + + for (const message of messages) { + try { + const sender = formatEmailSender(message.from); + const subject = message.subject || '(No subject)'; + const content = cleanEmailContent(message.body); + + if (!content || content.length < 10) continue; + + const sourceURL = + message.webLink || `https://outlook.office365.com/mail/inbox/id/${message.id}`; + + const importanceTag = message.importance === 'high' ? ' [HIGH]' : ''; + + const text = `## Email from ${sender}${importanceTag} + +**Subject:** ${subject} + +${content}`; + + activities.push(createActivityMessage({ text, sourceURL })); + } catch (error) { + console.error('Error processing received email:', error); + } + } + } catch (error) { + console.error('Error fetching received emails:', error); + } + + return activities; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function processSentEmails(client: any, lastSyncTime: string): Promise { + const activities = []; + + try { + const response = await client.get('/me/mailFolders/sentitems/messages', { + params: { + $filter: `sentDateTime ge ${lastSyncTime}`, + $orderby: 'sentDateTime desc', + $top: 50, + $select: 'id,subject,toRecipients,sentDateTime,body,webLink', + }, + }); + + const messages = response.data.value || []; + + for (const message of messages) { + try { + const recipients = (message.toRecipients || []) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .map((r: any) => r.emailAddress?.address || 'Unknown') + .join(', '); + const subject = message.subject || '(No subject)'; + const content = cleanEmailContent(message.body); + + if (!content || content.length < 10) continue; + + const sourceURL = + message.webLink || `https://outlook.office365.com/mail/sentitems/id/${message.id}`; + + const text = `## Sent to ${recipients} + +**Subject:** ${subject} + +${content}`; + + activities.push(createActivityMessage({ text, sourceURL })); + } catch (error) { + console.error('Error processing sent email:', error); + } + } + } catch (error) { + console.error('Error fetching sent emails:', error); + } + + return activities; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function processCalendarEvents(client: any, lastSyncTime: string): Promise { + const activities = []; + + try { + const now = new Date(); + const endTime = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days ahead + + const response = await client.get('/me/calendarView', { + params: { + startDateTime: lastSyncTime, + endDateTime: endTime.toISOString(), + $top: 50, + $select: + 'id,subject,start,end,location,organizer,attendees,webLink,isAllDay,bodyPreview,lastModifiedDateTime', + $orderby: 'start/dateTime asc', + }, + }); + + const events = response.data.value || []; + + for (const event of events) { + try { + // Only include events created or modified since last sync + if (event.lastModifiedDateTime && event.lastModifiedDateTime < lastSyncTime) continue; + + const subject = event.subject || '(No subject)'; + const startStr = event.start?.dateTime + ? new Date(event.start.dateTime).toLocaleString() + : 'TBD'; + const endStr = event.end?.dateTime + ? new Date(event.end.dateTime).toLocaleString() + : 'TBD'; + const location = event.location?.displayName || ''; + const organizer = event.organizer?.emailAddress?.name || ''; + const attendeeCount = event.attendees?.length || 0; + + const sourceURL = + event.webLink || `https://outlook.office365.com/calendar/item/${event.id}`; + + let text = `## Calendar: ${subject} + +**When:** ${startStr} - ${endStr}`; + if (event.isAllDay) text += ' (All day)'; + if (location) text += `\n**Where:** ${location}`; + if (organizer) text += `\n**Organizer:** ${organizer}`; + if (attendeeCount > 0) text += `\n**Attendees:** ${attendeeCount}`; + if (event.bodyPreview) text += `\n\n${event.bodyPreview}`; + + activities.push(createActivityMessage({ text, sourceURL })); + } catch (error) { + console.error('Error processing calendar event:', error); + } + } + } catch (error) { + console.error('Error fetching calendar events:', error); + } + + return activities; +} + +export const handleSchedule = async ( + config?: Record, + state?: Record +) => { + try { + if (!config?.access_token) { + return []; + } + + let settings = (state || {}) as OutlookSettings; + + const lastSyncTime = settings.lastSyncTime || getDefaultSyncTime(); + + const outlookConfig: OutlookConfig = { + access_token: config.access_token, + refresh_token: config.refresh_token || '', + client_id: config.client_id || '', + client_secret: config.client_secret || '', + }; + + const client = await getGraphClient(outlookConfig); + + // Get user profile if not cached + if (!settings.emailAddress) { + try { + const profile = await client.get('/me'); + settings.emailAddress = profile.data.mail || profile.data.userPrincipalName; + } catch (error) { + console.error('Error fetching user profile:', error); + } + } + + const messages = []; + + // Process received emails + const receivedActivities = await processReceivedEmails(client, lastSyncTime); + messages.push(...receivedActivities); + + // Process sent emails + const sentActivities = await processSentEmails(client, lastSyncTime); + messages.push(...sentActivities); + + // Process calendar events + const calendarActivities = await processCalendarEvents(client, lastSyncTime); + messages.push(...calendarActivities); + + // Update state + const newSyncTime = new Date().toISOString(); + messages.push({ + type: 'state', + data: { + ...settings, + lastSyncTime: newSyncTime, + lastUserEventTime: newSyncTime, + }, + }); + + return messages; + } catch (error) { + console.error('Error in handleSchedule:', error); + return []; + } +}; diff --git a/integrations/outlook/src/utils.ts b/integrations/outlook/src/utils.ts new file mode 100644 index 00000000..1601deed --- /dev/null +++ b/integrations/outlook/src/utils.ts @@ -0,0 +1,88 @@ +import axios, { AxiosInstance } from 'axios'; + +const GRAPH_BASE_URL = 'https://graph.microsoft.com/v1.0'; + +export interface OutlookConfig { + access_token: string; + refresh_token: string; + client_id: string; + client_secret: string; + redirect_uri?: string; +} + +/** + * Refresh the access token using the refresh token + */ +export async function refreshAccessToken(config: OutlookConfig): Promise { + try { + const response = await axios.post( + 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + new URLSearchParams({ + client_id: config.client_id, + client_secret: config.client_secret, + refresh_token: config.refresh_token, + grant_type: 'refresh_token', + scope: 'https://graph.microsoft.com/.default offline_access', + }), + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + } + ); + + return response.data.access_token; + } catch (error) { + console.error('Error refreshing access token:', error); + throw error; + } +} + +/** + * Create an authenticated Microsoft Graph API client + */ +export function createGraphClient(accessToken: string): AxiosInstance { + return axios.create({ + baseURL: GRAPH_BASE_URL, + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }); +} + +/** + * Get an authenticated Graph client, refreshing the token if needed + */ +export async function getGraphClient(config: OutlookConfig): Promise { + let accessToken = config.access_token; + + try { + // Test current token + await axios.get(`${GRAPH_BASE_URL}/me`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + } catch { + // Token expired, refresh it + accessToken = await refreshAccessToken(config); + } + + return createGraphClient(accessToken); +} + +/** + * Parse email body content + */ +export function parseEmailBody(body: { contentType: string; content: string }): string { + if (!body || !body.content) return ''; + return body.content; +} + +/** + * Format email sender for display + */ +export function formatEmailSender(from: { + emailAddress: { name: string; address: string }; +}): string { + if (!from || !from.emailAddress) return 'Unknown'; + const { name, address } = from.emailAddress; + return name ? `${name} <${address}>` : address; +} diff --git a/integrations/outlook/tsconfig.json b/integrations/outlook/tsconfig.json new file mode 100644 index 00000000..db52557c --- /dev/null +++ b/integrations/outlook/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020"], + "moduleResolution": "node", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "jsx": "react-jsx" + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "build", + "bin" + ], + "ts-node": { + "esm": true + } +} From 60a75271171d4379482c82c201a045cca2e343a0 Mon Sep 17 00:00:00 2001 From: Manik Date: Thu, 12 Mar 2026 00:52:45 +0530 Subject: [PATCH 2/2] fix: remove mcp cli type from Outlook spec per integrations v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In v2, CLI MCP integrations are implicit — no explicit mcp.type declaration needed in the spec. Co-Authored-By: Claude Sonnet 4.6 --- integrations/outlook/src/index.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/integrations/outlook/src/index.ts b/integrations/outlook/src/index.ts index 7649b88a..5478ba83 100644 --- a/integrations/outlook/src/index.ts +++ b/integrations/outlook/src/index.ts @@ -69,9 +69,6 @@ class OutlookCLI extends IntegrationCLI { description: 'Connect your workspace to Microsoft Outlook. Monitor emails, manage calendar events, and handle contacts via Microsoft Graph API', icon: 'outlook', - mcp: { - type: 'cli', - }, schedule: { frequency: '*/15 * * * *', },