MCP server providing Microsoft Outlook integration via Graph API: - Email: list, search, read, send, thread reconstruction - Calendar: list, create, decline, cancel, delete events - Folders: list, create, move emails - Inbox rules: list, create, reorder - MSAL device code flow auth with persistent token cache - Test mode with mock data - Comprehensive README with setup, config, and tool reference 20 MCP tools across 5 modules. Node.js >= 14. MIT license.
164 lines
6.1 KiB
JavaScript
164 lines
6.1 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* get-email-thread tool
|
|
* Fetches a set of email message IDs, cleans each body, strips quoted history
|
|
* from each message, and returns a single clean chronological thread.
|
|
*
|
|
* This dramatically reduces token usage vs calling read-emails on a chain —
|
|
* each message no longer carries the full history of every prior reply.
|
|
*/
|
|
|
|
const config = require('../config');
|
|
const { callGraphAPI } = require('../utils/graph-api');
|
|
const { ensureAuthenticated } = require('../auth');
|
|
const { buildThread } = require('../utils/threadBuilder');
|
|
|
|
const MAX_MESSAGES = 20;
|
|
|
|
/**
|
|
* Fetch all messages in a conversation across ALL folders (inbox + sent + etc.)
|
|
* using the conversationId filter on the global me/messages endpoint.
|
|
*/
|
|
async function fetchByConversationId(accessToken, conversationId) {
|
|
const allMessages = [];
|
|
let url = 'me/messages';
|
|
let params = {
|
|
$filter: `conversationId eq '${conversationId}'`,
|
|
$select: config.EMAIL_DETAIL_FIELDS,
|
|
$top: MAX_MESSAGES,
|
|
// NOTE: $orderby intentionally omitted — combining $filter on conversationId
|
|
// with $orderby causes a Graph API "InefficientFilter" 400 error.
|
|
// buildThread() handles chronological sorting in memory instead.
|
|
};
|
|
|
|
// Page through results (unlikely to exceed one page for most threads, but safe)
|
|
while (url) {
|
|
const page = await callGraphAPI(accessToken, 'GET', url, null, params);
|
|
if (page.value) allMessages.push(...page.value);
|
|
url = page['@odata.nextLink'] || null;
|
|
params = null; // params are embedded in nextLink on subsequent pages
|
|
}
|
|
|
|
return allMessages;
|
|
}
|
|
|
|
/**
|
|
* Handler for the get-email-thread tool.
|
|
* @param {object} args
|
|
* @param {string[]} [args.ids] - Explicit message IDs to include
|
|
* @param {string} [args.conversationId] - Fetch entire conversation from all folders
|
|
* @param {string} [args.subject] - Optional subject label for the thread header
|
|
*/
|
|
async function handleGetEmailThread(args) {
|
|
const { ids, conversationId, subject } = args || {};
|
|
|
|
const hasIds = Array.isArray(ids) && ids.length > 0;
|
|
const hasConvId = typeof conversationId === 'string' && conversationId.trim().length > 0;
|
|
|
|
if (!hasIds && !hasConvId) {
|
|
return {
|
|
content: [{ type: 'text', text: 'Provide either an ids array or a conversationId.' }]
|
|
};
|
|
}
|
|
|
|
if (hasIds && ids.length > MAX_MESSAGES) {
|
|
return {
|
|
content: [{ type: 'text', text: `Maximum ${MAX_MESSAGES} messages per thread request. ${ids.length} provided.` }]
|
|
};
|
|
}
|
|
|
|
let accessToken;
|
|
try {
|
|
accessToken = await ensureAuthenticated();
|
|
} catch {
|
|
return {
|
|
content: [{ type: 'text', text: "Authentication required. Please use the 'authenticate' tool first." }]
|
|
};
|
|
}
|
|
|
|
let messages = [];
|
|
let failCount = 0;
|
|
|
|
if (hasConvId) {
|
|
// Auto-fetch entire conversation from all folders (inbox + sent + etc.)
|
|
try {
|
|
messages = await fetchByConversationId(accessToken, conversationId.trim());
|
|
} catch (err) {
|
|
console.error(`[get-email-thread] conversationId fetch failed: ${err.message}`);
|
|
return {
|
|
content: [{ type: 'text', text: `Failed to fetch conversation: ${err.message}` }]
|
|
};
|
|
}
|
|
|
|
// If caller also passed explicit IDs, merge in any that weren't in the conversation result
|
|
if (hasIds) {
|
|
const fetchedIds = new Set(messages.map(m => m.id));
|
|
const extras = await Promise.all(
|
|
ids.filter(id => !fetchedIds.has(id)).map(async (id) => {
|
|
try {
|
|
return await callGraphAPI(accessToken, 'GET', `me/messages/${encodeURIComponent(id)}`, null, { $select: config.EMAIL_DETAIL_FIELDS });
|
|
} catch (err) {
|
|
console.error(`[get-email-thread] Failed to fetch ${id}: ${err.message}`);
|
|
failCount++;
|
|
return null;
|
|
}
|
|
})
|
|
);
|
|
messages.push(...extras.filter(Boolean));
|
|
}
|
|
} else {
|
|
// IDs-only path — fetch concurrently, same as before
|
|
const results = await Promise.all(ids.map(async (id) => {
|
|
try {
|
|
const message = await callGraphAPI(accessToken, 'GET', `me/messages/${encodeURIComponent(id)}`, null, { $select: config.EMAIL_DETAIL_FIELDS });
|
|
return { message, error: null };
|
|
} catch (err) {
|
|
console.error(`[get-email-thread] Failed to fetch ${id}: ${err.message}`);
|
|
return { message: null, error: err.message };
|
|
}
|
|
}));
|
|
messages = results.filter(r => r.message).map(r => r.message);
|
|
failCount = results.filter(r => r.error).length;
|
|
}
|
|
|
|
if (messages.length === 0) {
|
|
return {
|
|
content: [{ type: 'text', text: 'Could not retrieve any messages. Check IDs/conversationId and authentication.' }]
|
|
};
|
|
}
|
|
|
|
const thread = buildThread(messages, subject);
|
|
const note = failCount > 0 ? `\n\n(Note: ${failCount} message(s) could not be fetched and are excluded.)` : '';
|
|
|
|
return {
|
|
content: [{ type: 'text', text: thread + note }]
|
|
};
|
|
}
|
|
|
|
const threadTool = {
|
|
name: 'get-email-thread',
|
|
description: 'Fetch a complete email thread and return it as a single clean deduplicated conversation, sorted chronologically. Each message shows only its unique new content — quoted prior replies are stripped, and signatures are deduplicated per sender. Prefer conversationId (from list-emails or search-emails results) to automatically include both inbox AND sent items in the thread. Fall back to ids when you only have specific message IDs without a conversationId.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
conversationId: {
|
|
type: 'string',
|
|
description: 'Conversation ID (from list-emails). Fetches the COMPLETE thread from all folders including Sent Items. Preferred over ids for full thread reconstruction.'
|
|
},
|
|
ids: {
|
|
type: 'array',
|
|
items: { type: 'string' },
|
|
description: 'Array of specific message IDs to include (max 20). Use when you only have individual IDs and no conversationId.',
|
|
maxItems: MAX_MESSAGES
|
|
},
|
|
subject: {
|
|
type: 'string',
|
|
description: 'Optional: override the thread subject shown in the header'
|
|
}
|
|
}
|
|
},
|
|
handler: handleGetEmailThread
|
|
};
|
|
|
|
module.exports = { threadTool, handleGetEmailThread };
|