@teamnetwork/m365-mcp-server 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -119,10 +119,12 @@ If installed globally, you can use the `m365-mcp` command instead:
119
119
  | `list_mailboxes` | List configured accounts and mailboxes with read/write status |
120
120
  | `list_folders` | List mail folders (top-level or child folders) |
121
121
  | `list_messages` | List messages with optional filters: date range, subject, body keyword, sender |
122
- | `get_message` | Get full message details including body |
122
+ | `get_message` | Get full message details including body. Set `includeAttachments: true` to expand attachment metadata inline |
123
123
  | `send_message` | Send an email (non-readonly mailboxes only) |
124
124
  | `reply_message` | Reply or reply-all to a message (non-readonly mailboxes only) |
125
125
  | `move_message` | Move a message to another folder (non-readonly mailboxes only) |
126
+ | `list_message_attachments` | List all attachments on a message (file, item, and reference types) |
127
+ | `download_attachment` | Download a file attachment as base64. Images returned as MCP image content; reference attachments return their preview URL |
126
128
 
127
129
  ### Calendar
128
130
 
@@ -0,0 +1,71 @@
1
+ import { mapGraphError } from '../utils/errors.js';
2
+ // ── Helpers ────────────────────────────────────────────────────────────────
3
+ function deriveAttachmentType(odataType) {
4
+ if (odataType?.includes('referenceAttachment'))
5
+ return 'referenceAttachment';
6
+ if (odataType?.includes('itemAttachment'))
7
+ return 'itemAttachment';
8
+ return 'fileAttachment';
9
+ }
10
+ function normaliseAttachment(raw) {
11
+ const odataType = raw['@odata.type'];
12
+ const attachmentType = deriveAttachmentType(odataType);
13
+ const meta = {
14
+ id: raw['id'],
15
+ name: raw['name'],
16
+ contentType: raw['contentType'] ?? 'application/octet-stream',
17
+ size: raw['size'] ?? 0,
18
+ isInline: raw['isInline'] ?? false,
19
+ attachmentType,
20
+ };
21
+ if (attachmentType === 'referenceAttachment' && raw['previewUrl']) {
22
+ meta.previewUrl = raw['previewUrl'];
23
+ }
24
+ return meta;
25
+ }
26
+ // ── API calls ──────────────────────────────────────────────────────────────
27
+ export async function listAttachments(client, email, messageId) {
28
+ try {
29
+ const response = await client
30
+ .api(`/users/${email}/messages/${messageId}/attachments`)
31
+ .select('id,name,contentType,size,isInline,previewUrl')
32
+ .get();
33
+ return (response.value ?? []).map(normaliseAttachment);
34
+ }
35
+ catch (err) {
36
+ throw mapGraphError(err, 'list_message_attachments');
37
+ }
38
+ }
39
+ export async function downloadAttachment(client, email, messageId, attachmentId) {
40
+ try {
41
+ // Fetch the full attachment record including contentBytes for file attachments.
42
+ // contentBytes is already base64-encoded by the Graph API.
43
+ const raw = await client
44
+ .api(`/users/${email}/messages/${messageId}/attachments/${attachmentId}`)
45
+ .select('id,name,contentType,isInline,contentBytes,previewUrl,@odata.type')
46
+ .get();
47
+ const attachmentType = deriveAttachmentType(raw['@odata.type']);
48
+ if (attachmentType === 'referenceAttachment') {
49
+ return {
50
+ kind: 'reference',
51
+ previewUrl: raw['previewUrl'] ?? raw['name'],
52
+ };
53
+ }
54
+ if (attachmentType === 'itemAttachment') {
55
+ return {
56
+ kind: 'item',
57
+ name: raw['name'],
58
+ };
59
+ }
60
+ // fileAttachment — contentBytes is already base64 from Graph
61
+ return {
62
+ kind: 'file',
63
+ name: raw['name'],
64
+ mimeType: raw['contentType'] ?? 'application/octet-stream',
65
+ contentBytes: raw['contentBytes'],
66
+ };
67
+ }
68
+ catch (err) {
69
+ throw mapGraphError(err, 'download_attachment');
70
+ }
71
+ }
@@ -22,7 +22,7 @@ export async function listEvents(client, email, params) {
22
22
  startDateTime: params.startDate,
23
23
  endDateTime: params.endDate,
24
24
  })
25
- .select('id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeetingUrl')
25
+ .select(params.select)
26
26
  .top(params.maxResults)
27
27
  .orderby('start/dateTime')
28
28
  .get();
@@ -23,7 +23,7 @@ export async function listMessages(client, email, params) {
23
23
  const folderId = params.folderId === 'Inbox' ? 'Inbox' : params.folderId;
24
24
  let req = client
25
25
  .api(`/users/${email}/mailFolders/${folderId}/messages`)
26
- .select('id,subject,from,receivedDateTime,bodyPreview,hasAttachments,isRead')
26
+ .select(params.select)
27
27
  .top(params.maxResults)
28
28
  .orderby('receivedDateTime desc');
29
29
  if (params.bodySearch) {
@@ -69,15 +69,22 @@ export async function listMessages(client, email, params) {
69
69
  throw mapGraphError(err, 'list_messages');
70
70
  }
71
71
  }
72
- export async function getMessage(client, email, messageId, includeBody) {
72
+ export async function getMessage(client, email, messageId, includeBody, includeAttachments) {
73
73
  try {
74
- const select = includeBody
75
- ? 'id,subject,from,toRecipients,ccRecipients,body,receivedDateTime,hasAttachments,isRead'
76
- : 'id,subject,from,toRecipients,ccRecipients,receivedDateTime,hasAttachments,isRead';
77
- return await client
74
+ const selectFields = [
75
+ 'id', 'subject', 'from', 'toRecipients', 'ccRecipients',
76
+ 'receivedDateTime', 'hasAttachments', 'isRead',
77
+ ];
78
+ if (includeBody)
79
+ selectFields.push('body');
80
+ let req = client
78
81
  .api(`/users/${email}/messages/${messageId}`)
79
- .select(select)
80
- .get();
82
+ .select(selectFields.join(','));
83
+ if (includeAttachments) {
84
+ // Expand attachments inline — avoids a second round-trip for the caller
85
+ req = req.expand('attachments($select=id,name,contentType,size,isInline)');
86
+ }
87
+ return await req.get();
81
88
  }
82
89
  catch (err) {
83
90
  throw mapGraphError(err, 'get_message');
@@ -2,19 +2,31 @@ import { ListEventsSchema } from '../schemas.js';
2
2
  import { createGraphClient } from '../../graph/clientFactory.js';
3
3
  import { listEvents } from '../../graph/calendar.js';
4
4
  import { assertMailboxConfigured } from '../../middleware/readonlyGuard.js';
5
- import { errorResponse } from '../../utils/errors.js';
5
+ import { errorResponse, ToolError } from '../../utils/errors.js';
6
6
  import { logger } from '../../utils/logger.js';
7
+ import { resolveSelect, SelectValidationError, EVENT_SELECT_FIELDS, EVENT_SELECT_DEFAULT } from '../../utils/selectFields.js';
7
8
  export function registerListEvents(server, config, auth) {
8
9
  server.tool('list_events', 'List calendar events within a date range. Recurring events are expanded into individual instances.', ListEventsSchema.shape, async (params) => {
9
10
  logger.info('list_events', { accountId: params.accountId, mailbox: params.mailbox });
10
11
  try {
11
12
  assertMailboxConfigured(config, params.accountId, params.mailbox);
13
+ let select;
14
+ try {
15
+ select = resolveSelect(params.select, EVENT_SELECT_FIELDS, EVENT_SELECT_DEFAULT);
16
+ }
17
+ catch (err) {
18
+ if (err instanceof SelectValidationError) {
19
+ return errorResponse(new ToolError(err.message, 'INVALID_SELECT_FIELD'));
20
+ }
21
+ throw err;
22
+ }
12
23
  const token = await auth.getAccessToken(params.accountId);
13
24
  const client = createGraphClient(token);
14
25
  const result = await listEvents(client, params.mailbox, {
15
26
  startDate: params.startDate,
16
27
  endDate: params.endDate,
17
28
  maxResults: params.maxResults,
29
+ select,
18
30
  ...(params.calendarId !== undefined && { calendarId: params.calendarId }),
19
31
  });
20
32
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
@@ -0,0 +1,69 @@
1
+ import { DownloadAttachmentSchema } from '../schemas.js';
2
+ import { createGraphClient } from '../../graph/clientFactory.js';
3
+ import { downloadAttachment } from '../../graph/attachments.js';
4
+ import { assertMailboxConfigured } from '../../middleware/readonlyGuard.js';
5
+ import { errorResponse } from '../../utils/errors.js';
6
+ import { logger } from '../../utils/logger.js';
7
+ export function registerDownloadAttachment(server, config, auth) {
8
+ server.tool('download_attachment', 'Download a file attachment from an email message. Returns base64-encoded file content for file attachments, the preview URL for reference attachments (e.g. SharePoint links), or a descriptive message for embedded item attachments.', DownloadAttachmentSchema.shape, async (params) => {
9
+ logger.info('download_attachment', {
10
+ accountId: params.accountId,
11
+ mailbox: params.mailbox,
12
+ });
13
+ try {
14
+ assertMailboxConfigured(config, params.accountId, params.mailbox);
15
+ const token = await auth.getAccessToken(params.accountId);
16
+ const client = createGraphClient(token);
17
+ const result = await downloadAttachment(client, params.mailbox, params.messageId, params.attachmentId);
18
+ switch (result.kind) {
19
+ case 'file':
20
+ // Images are returned as MCP image content; all other files as structured text
21
+ // containing the base64 payload, mimeType, and fileName for the caller to handle.
22
+ if (result.mimeType.startsWith('image/')) {
23
+ return {
24
+ content: [
25
+ {
26
+ type: 'image',
27
+ data: result.contentBytes,
28
+ mimeType: result.mimeType,
29
+ },
30
+ ],
31
+ };
32
+ }
33
+ return {
34
+ content: [
35
+ {
36
+ type: 'text',
37
+ text: JSON.stringify({
38
+ fileName: result.name,
39
+ mimeType: result.mimeType,
40
+ encoding: 'base64',
41
+ data: result.contentBytes,
42
+ }, null, 2),
43
+ },
44
+ ],
45
+ };
46
+ case 'reference':
47
+ return {
48
+ content: [{ type: 'text', text: result.previewUrl }],
49
+ };
50
+ case 'item':
51
+ return {
52
+ content: [
53
+ {
54
+ type: 'text',
55
+ text: `itemAttachment: "${result.name}" — embedded Outlook item, not downloadable as a file`,
56
+ },
57
+ ],
58
+ };
59
+ }
60
+ }
61
+ catch (err) {
62
+ logger.error('download_attachment failed', {
63
+ accountId: params.accountId,
64
+ mailbox: params.mailbox,
65
+ });
66
+ return errorResponse(err);
67
+ }
68
+ });
69
+ }
@@ -5,13 +5,13 @@ import { assertMailboxConfigured } from '../../middleware/readonlyGuard.js';
5
5
  import { errorResponse } from '../../utils/errors.js';
6
6
  import { logger } from '../../utils/logger.js';
7
7
  export function registerGetMessage(server, config, auth) {
8
- server.tool('get_message', 'Get the full details of a specific email message by ID, including body content.', GetMessageSchema.shape, async (params) => {
8
+ server.tool('get_message', 'Get the full details of a specific email message by ID. Set includeBody to receive the message body, includeAttachments to expand attachment metadata inline.', GetMessageSchema.shape, async (params) => {
9
9
  logger.info('get_message', { accountId: params.accountId, mailbox: params.mailbox });
10
10
  try {
11
11
  assertMailboxConfigured(config, params.accountId, params.mailbox);
12
12
  const token = await auth.getAccessToken(params.accountId);
13
13
  const client = createGraphClient(token);
14
- const result = await getMessage(client, params.mailbox, params.messageId, params.includeBody);
14
+ const result = await getMessage(client, params.mailbox, params.messageId, params.includeBody, params.includeAttachments);
15
15
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
16
16
  }
17
17
  catch (err) {
@@ -0,0 +1,30 @@
1
+ import { ListMessageAttachmentsSchema } from '../schemas.js';
2
+ import { createGraphClient } from '../../graph/clientFactory.js';
3
+ import { listAttachments } from '../../graph/attachments.js';
4
+ import { assertMailboxConfigured } from '../../middleware/readonlyGuard.js';
5
+ import { errorResponse } from '../../utils/errors.js';
6
+ import { logger } from '../../utils/logger.js';
7
+ export function registerListMessageAttachments(server, config, auth) {
8
+ server.tool('list_message_attachments', 'List all attachments on a specific email message. Returns metadata for file, item, and reference attachments. Use download_attachment to retrieve file content.', ListMessageAttachmentsSchema.shape, async (params) => {
9
+ logger.info('list_message_attachments', {
10
+ accountId: params.accountId,
11
+ mailbox: params.mailbox,
12
+ });
13
+ try {
14
+ assertMailboxConfigured(config, params.accountId, params.mailbox);
15
+ const token = await auth.getAccessToken(params.accountId);
16
+ const client = createGraphClient(token);
17
+ const attachments = await listAttachments(client, params.mailbox, params.messageId);
18
+ return {
19
+ content: [{ type: 'text', text: JSON.stringify({ value: attachments }, null, 2) }],
20
+ };
21
+ }
22
+ catch (err) {
23
+ logger.error('list_message_attachments failed', {
24
+ accountId: params.accountId,
25
+ mailbox: params.mailbox,
26
+ });
27
+ return errorResponse(err);
28
+ }
29
+ });
30
+ }
@@ -2,8 +2,9 @@ import { ListMessagesSchema } from '../schemas.js';
2
2
  import { createGraphClient } from '../../graph/clientFactory.js';
3
3
  import { listMessages } from '../../graph/email.js';
4
4
  import { assertMailboxConfigured } from '../../middleware/readonlyGuard.js';
5
- import { errorResponse } from '../../utils/errors.js';
5
+ import { errorResponse, ToolError } from '../../utils/errors.js';
6
6
  import { logger } from '../../utils/logger.js';
7
+ import { resolveSelect, SelectValidationError, MESSAGE_SELECT_FIELDS, MESSAGE_SELECT_DEFAULT } from '../../utils/selectFields.js';
7
8
  export function registerListMessages(server, config, auth) {
8
9
  server.tool('list_messages', 'List email messages in a mailbox folder with optional filters for date range, subject, body content, and sender. Returns message metadata (not full body) to keep response size small.', ListMessagesSchema.shape, async (params) => {
9
10
  logger.info('list_messages', {
@@ -14,11 +15,22 @@ export function registerListMessages(server, config, auth) {
14
15
  });
15
16
  try {
16
17
  assertMailboxConfigured(config, params.accountId, params.mailbox);
18
+ let select;
19
+ try {
20
+ select = resolveSelect(params.select, MESSAGE_SELECT_FIELDS, MESSAGE_SELECT_DEFAULT);
21
+ }
22
+ catch (err) {
23
+ if (err instanceof SelectValidationError) {
24
+ return errorResponse(new ToolError(err.message, 'INVALID_SELECT_FIELD'));
25
+ }
26
+ throw err;
27
+ }
17
28
  const token = await auth.getAccessToken(params.accountId);
18
29
  const client = createGraphClient(token);
19
30
  const result = await listMessages(client, params.mailbox, {
20
31
  folderId: params.folderId,
21
32
  maxResults: params.maxResults,
33
+ select,
22
34
  ...(params.startDate !== undefined && { startDate: params.startDate }),
23
35
  ...(params.endDate !== undefined && { endDate: params.endDate }),
24
36
  ...(params.subject !== undefined && { subject: params.subject }),
@@ -6,6 +6,8 @@ import { registerGetMessage } from './email/getMessage.js';
6
6
  import { registerSendMessage } from './email/sendMessage.js';
7
7
  import { registerReplyMessage } from './email/replyMessage.js';
8
8
  import { registerMoveMessage } from './email/moveMessage.js';
9
+ import { registerListMessageAttachments } from './email/listMessageAttachments.js';
10
+ import { registerDownloadAttachment } from './email/downloadAttachment.js';
9
11
  // Calendar tools
10
12
  import { registerListCalendars } from './calendar/listCalendars.js';
11
13
  import { registerListEvents } from './calendar/listEvents.js';
@@ -20,6 +22,8 @@ function registerEmailTools(server, config, auth) {
20
22
  registerSendMessage(server, config, auth);
21
23
  registerReplyMessage(server, config, auth);
22
24
  registerMoveMessage(server, config, auth);
25
+ registerListMessageAttachments(server, config, auth);
26
+ registerDownloadAttachment(server, config, auth);
23
27
  }
24
28
  function registerCalendarTools(server, config, auth) {
25
29
  registerListCalendars(server, config, auth);
@@ -19,10 +19,19 @@ export const ListMessagesSchema = AccountMailbox.extend({
19
19
  bodySearch: z.string().optional().describe('Full-text search in message body. Note: cannot be combined with $filter — date/sender filters are applied client-side when this is used.'),
20
20
  sender: z.string().email().optional().describe('Filter messages from this sender email address'),
21
21
  maxResults: z.number().int().min(1).max(100).default(25).describe('Maximum number of messages to return (1–100)'),
22
+ select: z.array(z.string()).optional().describe('Fields to return. Omit for defaults (subject, from, receivedDateTime, isRead, hasAttachments, bodyPreview). Valid fields: id, subject, from, toRecipients, ccRecipients, bccRecipients, receivedDateTime, sentDateTime, bodyPreview, body, hasAttachments, attachmentCount, importance, isRead, isDraft, webLink, parentFolderId, conversationId'),
22
23
  });
23
24
  export const GetMessageSchema = AccountMailbox.extend({
24
25
  messageId: z.string().describe('The message ID'),
25
26
  includeBody: z.boolean().default(true).describe('Whether to include the full message body in the response'),
27
+ includeAttachments: z.boolean().default(false).describe('Whether to expand and include attachment metadata in the response'),
28
+ });
29
+ export const ListMessageAttachmentsSchema = AccountMailbox.extend({
30
+ messageId: z.string().describe('The message ID to list attachments for'),
31
+ });
32
+ export const DownloadAttachmentSchema = AccountMailbox.extend({
33
+ messageId: z.string().describe('The message ID'),
34
+ attachmentId: z.string().describe('The attachment ID to download'),
26
35
  });
27
36
  export const SendMessageSchema = AccountMailbox.extend({
28
37
  to: z.array(z.string().email()).min(1).describe('List of recipient email addresses'),
@@ -50,6 +59,7 @@ export const ListEventsSchema = AccountMailbox.extend({
50
59
  startDate: z.string().datetime().describe('Start of the date range (ISO 8601). Recurring events are expanded within this window.'),
51
60
  endDate: z.string().datetime().describe('End of the date range (ISO 8601)'),
52
61
  maxResults: z.number().int().min(1).max(100).default(25),
62
+ select: z.array(z.string()).optional().describe('Fields to return. Omit for defaults (subject, start, end, location, isOnlineMeeting). Valid fields: id, subject, start, end, location, isOnlineMeeting, onlineMeetingUrl, organizer, attendees, body, bodyPreview, categories, isRecurring, recurrence, sensitivity, showAs, uid'),
53
63
  });
54
64
  export const GetEventSchema = AccountMailbox.extend({
55
65
  eventId: z.string().describe('The event ID'),
@@ -0,0 +1,83 @@
1
+ // Allowed $select field lists and validation for list_messages and list_events.
2
+ // id is always forced into the select regardless of what the caller requests.
3
+ export const MESSAGE_SELECT_FIELDS = new Set([
4
+ 'id',
5
+ 'subject',
6
+ 'from',
7
+ 'toRecipients',
8
+ 'ccRecipients',
9
+ 'bccRecipients',
10
+ 'receivedDateTime',
11
+ 'sentDateTime',
12
+ 'bodyPreview',
13
+ 'body',
14
+ 'hasAttachments',
15
+ 'attachmentCount',
16
+ 'importance',
17
+ 'isRead',
18
+ 'isDraft',
19
+ 'webLink',
20
+ 'parentFolderId',
21
+ 'conversationId',
22
+ ]);
23
+ export const EVENT_SELECT_FIELDS = new Set([
24
+ 'id',
25
+ 'subject',
26
+ 'start',
27
+ 'end',
28
+ 'location',
29
+ 'isOnlineMeeting',
30
+ 'onlineMeetingUrl',
31
+ 'organizer',
32
+ 'attendees',
33
+ 'body',
34
+ 'bodyPreview',
35
+ 'categories',
36
+ 'isRecurring',
37
+ 'recurrence',
38
+ 'sensitivity',
39
+ 'showAs',
40
+ 'uid',
41
+ ]);
42
+ export const MESSAGE_SELECT_DEFAULT = [
43
+ 'subject',
44
+ 'from',
45
+ 'receivedDateTime',
46
+ 'isRead',
47
+ 'hasAttachments',
48
+ 'bodyPreview',
49
+ ];
50
+ export const EVENT_SELECT_DEFAULT = [
51
+ 'subject',
52
+ 'start',
53
+ 'end',
54
+ 'location',
55
+ 'isOnlineMeeting',
56
+ ];
57
+ /**
58
+ * Validate, normalise, and force-include `id` in a select list.
59
+ * Returns the final comma-separated string for Graph API $select,
60
+ * or throws with a descriptive message listing valid fields.
61
+ */
62
+ export function resolveSelect(rawSelect, allowed, defaults) {
63
+ // Empty array → treat as omitted, use defaults
64
+ const fields = rawSelect && rawSelect.length > 0
65
+ ? rawSelect.map((f) => f.trim()).filter(Boolean)
66
+ : defaults;
67
+ // Validate each field
68
+ for (const field of fields) {
69
+ if (!allowed.has(field)) {
70
+ throw new SelectValidationError(field, allowed);
71
+ }
72
+ }
73
+ // id must always be present
74
+ const withId = fields.includes('id') ? fields : ['id', ...fields];
75
+ return withId.join(',');
76
+ }
77
+ export class SelectValidationError extends Error {
78
+ constructor(invalidField, allowed) {
79
+ const validList = [...allowed].sort().join(', ');
80
+ super(`Invalid select field: "${invalidField}". Valid fields: ${validList}`);
81
+ this.name = 'SelectValidationError';
82
+ }
83
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamnetwork/m365-mcp-server",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "MCP server for Microsoft 365 Email and Calendar access using client credentials (app-only auth). Supports multiple accounts, shared mailboxes, per-mailbox read-only access, folder navigation, and date/content filtering.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",