outlook-mcp/tools/get-email-thread.js
Seton Carmichael a7886b5b2b Initial commit: Outlook MCP Server v1.0.0
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.
2026-06-21 19:39:31 -04:00

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 };