@teamnetwork/m365-mcp-server 1.0.0
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/config.example.json +27 -0
- package/dist/auth/AuthManager.js +19 -0
- package/dist/auth/TokenCache.js +25 -0
- package/dist/config/loader.js +31 -0
- package/dist/config/schema.js +23 -0
- package/dist/config/types.js +1 -0
- package/dist/graph/calendar.js +109 -0
- package/dist/graph/clientFactory.js +8 -0
- package/dist/graph/email.js +129 -0
- package/dist/index.js +30 -0
- package/dist/middleware/readonlyGuard.js +29 -0
- package/dist/tools/calendar/createEvent.js +33 -0
- package/dist/tools/calendar/getEvent.js +22 -0
- package/dist/tools/calendar/listCalendars.js +22 -0
- package/dist/tools/calendar/listEvents.js +27 -0
- package/dist/tools/calendar/updateEvent.js +31 -0
- package/dist/tools/email/getMessage.js +22 -0
- package/dist/tools/email/listFolders.js +24 -0
- package/dist/tools/email/listMailboxes.js +22 -0
- package/dist/tools/email/listMessages.js +35 -0
- package/dist/tools/email/moveMessage.js +24 -0
- package/dist/tools/email/replyMessage.js +26 -0
- package/dist/tools/email/sendMessage.js +30 -0
- package/dist/tools/registry.js +35 -0
- package/dist/tools/schemas.js +80 -0
- package/dist/utils/errors.js +48 -0
- package/dist/utils/logger.js +32 -0
- package/package.json +47 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"connection": {
|
|
3
|
+
"tenantId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
|
4
|
+
"clientId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
|
5
|
+
"clientSecret": "your-default-client-secret"
|
|
6
|
+
},
|
|
7
|
+
"accounts": [
|
|
8
|
+
{
|
|
9
|
+
"id": "main",
|
|
10
|
+
"displayName": "Main Organisation",
|
|
11
|
+
"mailboxes": [
|
|
12
|
+
{ "email": "user@org.com", "readonly": false, "displayName": "Primary User" },
|
|
13
|
+
{ "email": "shared@org.com", "readonly": true, "displayName": "Shared Mailbox (read-only)" }
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"id": "partner",
|
|
18
|
+
"displayName": "Partner Tenant",
|
|
19
|
+
"tenantId": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy",
|
|
20
|
+
"clientId": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy",
|
|
21
|
+
"clientSecret": "partner-specific-secret",
|
|
22
|
+
"mailboxes": [
|
|
23
|
+
{ "email": "partner@other.com", "readonly": false }
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ToolError } from '../utils/errors.js';
|
|
2
|
+
const GRAPH_SCOPE = 'https://graph.microsoft.com/.default';
|
|
3
|
+
export class AuthManager {
|
|
4
|
+
cache;
|
|
5
|
+
constructor(cache) {
|
|
6
|
+
this.cache = cache;
|
|
7
|
+
}
|
|
8
|
+
async getAccessToken(accountId) {
|
|
9
|
+
const client = this.cache.getClient(accountId);
|
|
10
|
+
const result = await client.acquireTokenByClientCredential({
|
|
11
|
+
scopes: [GRAPH_SCOPE],
|
|
12
|
+
});
|
|
13
|
+
if (!result?.accessToken) {
|
|
14
|
+
throw new ToolError(`Failed to acquire access token for account "${accountId}" — check tenant ID, client ID, and client secret`, 'AUTH_FAILED');
|
|
15
|
+
}
|
|
16
|
+
// Token value is intentionally not logged — see utils/logger.ts scrubbing rules
|
|
17
|
+
return result.accessToken;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ConfidentialClientApplication } from '@azure/msal-node';
|
|
2
|
+
import { resolveCredentials } from '../config/loader.js';
|
|
3
|
+
export class TokenCache {
|
|
4
|
+
clients = new Map();
|
|
5
|
+
constructor(config) {
|
|
6
|
+
for (const account of config.accounts) {
|
|
7
|
+
const creds = resolveCredentials(config, account);
|
|
8
|
+
this.clients.set(account.id, new ConfidentialClientApplication({
|
|
9
|
+
auth: {
|
|
10
|
+
clientId: creds.clientId,
|
|
11
|
+
clientSecret: creds.clientSecret,
|
|
12
|
+
authority: `https://login.microsoftonline.com/${creds.tenantId}`,
|
|
13
|
+
},
|
|
14
|
+
// No cache plugin = default in-memory cache only (no disk persistence)
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
getClient(accountId) {
|
|
19
|
+
const client = this.clients.get(accountId);
|
|
20
|
+
if (!client) {
|
|
21
|
+
throw new Error(`Unknown account ID: "${accountId}". Check your config file.`);
|
|
22
|
+
}
|
|
23
|
+
return client;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { AppConfigSchema } from './schema.js';
|
|
3
|
+
export function loadConfig() {
|
|
4
|
+
const configPath = process.env['M365_CONFIG_PATH'];
|
|
5
|
+
if (!configPath) {
|
|
6
|
+
throw new Error('M365_CONFIG_PATH environment variable is not set');
|
|
7
|
+
}
|
|
8
|
+
let raw;
|
|
9
|
+
try {
|
|
10
|
+
raw = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
throw new Error(`Failed to read config file at "${configPath}": ${err.message}`);
|
|
14
|
+
}
|
|
15
|
+
const result = AppConfigSchema.safeParse(raw);
|
|
16
|
+
if (!result.success) {
|
|
17
|
+
const issues = result.error.issues
|
|
18
|
+
.map((i) => ` - ${i.path.join('.')}: ${i.message}`)
|
|
19
|
+
.join('\n');
|
|
20
|
+
throw new Error(`Config validation failed:\n${issues}`);
|
|
21
|
+
}
|
|
22
|
+
return Object.freeze(result.data);
|
|
23
|
+
}
|
|
24
|
+
/** Merge connection defaults with per-account overrides. Account fields win. */
|
|
25
|
+
export function resolveCredentials(config, account) {
|
|
26
|
+
return {
|
|
27
|
+
tenantId: account.tenantId ?? config.connection.tenantId,
|
|
28
|
+
clientId: account.clientId ?? config.connection.clientId,
|
|
29
|
+
clientSecret: account.clientSecret ?? config.connection.clientSecret,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const ConnectionConfigSchema = z.object({
|
|
3
|
+
tenantId: z.string().uuid('connection.tenantId must be a valid UUID'),
|
|
4
|
+
clientId: z.string().uuid('connection.clientId must be a valid UUID'),
|
|
5
|
+
clientSecret: z.string().min(1, 'connection.clientSecret must not be empty'),
|
|
6
|
+
});
|
|
7
|
+
export const MailboxConfigSchema = z.object({
|
|
8
|
+
email: z.string().email('mailbox.email must be a valid email address'),
|
|
9
|
+
readonly: z.boolean(),
|
|
10
|
+
displayName: z.string().optional(),
|
|
11
|
+
});
|
|
12
|
+
export const AccountConfigSchema = z.object({
|
|
13
|
+
id: z.string().min(1, 'account.id must not be empty'),
|
|
14
|
+
displayName: z.string().optional(),
|
|
15
|
+
tenantId: z.string().uuid().optional(),
|
|
16
|
+
clientId: z.string().uuid().optional(),
|
|
17
|
+
clientSecret: z.string().min(1).optional(),
|
|
18
|
+
mailboxes: z.array(MailboxConfigSchema).min(1, 'Each account must have at least one mailbox'),
|
|
19
|
+
});
|
|
20
|
+
export const AppConfigSchema = z.object({
|
|
21
|
+
connection: ConnectionConfigSchema,
|
|
22
|
+
accounts: z.array(AccountConfigSchema).min(1, 'Config must have at least one account'),
|
|
23
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { mapGraphError } from '../utils/errors.js';
|
|
2
|
+
export async function listCalendars(client, email) {
|
|
3
|
+
try {
|
|
4
|
+
return await client
|
|
5
|
+
.api(`/users/${email}/calendars`)
|
|
6
|
+
.select('id,name,canEdit,color,isDefaultCalendar')
|
|
7
|
+
.get();
|
|
8
|
+
}
|
|
9
|
+
catch (err) {
|
|
10
|
+
throw mapGraphError(err, 'list_calendars');
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export async function listEvents(client, email, params) {
|
|
14
|
+
try {
|
|
15
|
+
// calendarView expands recurring events into individual instances within the window
|
|
16
|
+
const basePath = params.calendarId
|
|
17
|
+
? `/users/${email}/calendars/${params.calendarId}/calendarView`
|
|
18
|
+
: `/users/${email}/calendarView`;
|
|
19
|
+
return await client
|
|
20
|
+
.api(basePath)
|
|
21
|
+
.query({
|
|
22
|
+
startDateTime: params.startDate,
|
|
23
|
+
endDateTime: params.endDate,
|
|
24
|
+
})
|
|
25
|
+
.select('id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeetingUrl')
|
|
26
|
+
.top(params.maxResults)
|
|
27
|
+
.orderby('start/dateTime')
|
|
28
|
+
.get();
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
throw mapGraphError(err, 'list_events');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export async function getEvent(client, email, eventId) {
|
|
35
|
+
try {
|
|
36
|
+
return await client
|
|
37
|
+
.api(`/users/${email}/events/${eventId}`)
|
|
38
|
+
.select('id,subject,body,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting,recurrence')
|
|
39
|
+
.get();
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
throw mapGraphError(err, 'get_event');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function createEvent(client, email, params) {
|
|
46
|
+
try {
|
|
47
|
+
const basePath = params.calendarId
|
|
48
|
+
? `/users/${email}/calendars/${params.calendarId}/events`
|
|
49
|
+
: `/users/${email}/events`;
|
|
50
|
+
const attendees = (params.attendees ?? []).map((addr) => ({
|
|
51
|
+
emailAddress: { address: addr },
|
|
52
|
+
type: 'required',
|
|
53
|
+
}));
|
|
54
|
+
const body = {
|
|
55
|
+
subject: params.subject,
|
|
56
|
+
start: { dateTime: params.startTime, timeZone: params.timeZone },
|
|
57
|
+
end: { dateTime: params.endTime, timeZone: params.timeZone },
|
|
58
|
+
isOnlineMeeting: params.isOnlineMeeting,
|
|
59
|
+
};
|
|
60
|
+
if (params.body !== undefined) {
|
|
61
|
+
body['body'] = {
|
|
62
|
+
contentType: params.bodyType === 'html' ? 'HTML' : 'Text',
|
|
63
|
+
content: params.body,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (params.location) {
|
|
67
|
+
body['location'] = { displayName: params.location };
|
|
68
|
+
}
|
|
69
|
+
if (attendees.length > 0) {
|
|
70
|
+
body['attendees'] = attendees;
|
|
71
|
+
}
|
|
72
|
+
return await client.api(basePath).post(body);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
throw mapGraphError(err, 'create_event');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export async function updateEvent(client, email, eventId, params) {
|
|
79
|
+
try {
|
|
80
|
+
const patch = {};
|
|
81
|
+
if (params.subject !== undefined)
|
|
82
|
+
patch['subject'] = params.subject;
|
|
83
|
+
if (params.body !== undefined) {
|
|
84
|
+
patch['body'] = {
|
|
85
|
+
contentType: (params.bodyType ?? 'text') === 'html' ? 'HTML' : 'Text',
|
|
86
|
+
content: params.body,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (params.startTime !== undefined) {
|
|
90
|
+
patch['start'] = { dateTime: params.startTime, timeZone: params.timeZone ?? 'UTC' };
|
|
91
|
+
}
|
|
92
|
+
if (params.endTime !== undefined) {
|
|
93
|
+
patch['end'] = { dateTime: params.endTime, timeZone: params.timeZone ?? 'UTC' };
|
|
94
|
+
}
|
|
95
|
+
if (params.location !== undefined) {
|
|
96
|
+
patch['location'] = { displayName: params.location };
|
|
97
|
+
}
|
|
98
|
+
if (params.attendees !== undefined) {
|
|
99
|
+
patch['attendees'] = params.attendees.map((addr) => ({
|
|
100
|
+
emailAddress: { address: addr },
|
|
101
|
+
type: 'required',
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
return await client.api(`/users/${email}/events/${eventId}`).patch(patch);
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
throw mapGraphError(err, 'update_event');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { mapGraphError } from '../utils/errors.js';
|
|
2
|
+
export async function listFolders(client, email, params) {
|
|
3
|
+
try {
|
|
4
|
+
const basePath = params.parentFolderId
|
|
5
|
+
? `/users/${email}/mailFolders/${params.parentFolderId}/childFolders`
|
|
6
|
+
: `/users/${email}/mailFolders`;
|
|
7
|
+
const response = await client
|
|
8
|
+
.api(basePath)
|
|
9
|
+
.select('id,displayName,totalItemCount,unreadItemCount,childFolderCount')
|
|
10
|
+
.top(100)
|
|
11
|
+
.get();
|
|
12
|
+
return response;
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
throw mapGraphError(err, 'list_folders');
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export async function listMessages(client, email, params) {
|
|
19
|
+
try {
|
|
20
|
+
// Body search requires $search which cannot be combined with $filter.
|
|
21
|
+
// When bodySearch is provided alongside date/sender filters, we use $search
|
|
22
|
+
// for the body keyword and apply date/sender filtering client-side on the results.
|
|
23
|
+
const folderId = params.folderId === 'Inbox' ? 'Inbox' : params.folderId;
|
|
24
|
+
let req = client
|
|
25
|
+
.api(`/users/${email}/mailFolders/${folderId}/messages`)
|
|
26
|
+
.select('id,subject,from,receivedDateTime,bodyPreview,hasAttachments,isRead')
|
|
27
|
+
.top(params.maxResults)
|
|
28
|
+
.orderby('receivedDateTime desc');
|
|
29
|
+
if (params.bodySearch) {
|
|
30
|
+
// $search mode: body keyword search. Date/sender applied client-side below.
|
|
31
|
+
req = req.search(`"body:${params.bodySearch}"`);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
// $filter mode: supports date range and sender.
|
|
35
|
+
const filters = [];
|
|
36
|
+
if (params.startDate)
|
|
37
|
+
filters.push(`receivedDateTime ge ${params.startDate}`);
|
|
38
|
+
if (params.endDate)
|
|
39
|
+
filters.push(`receivedDateTime le ${params.endDate}`);
|
|
40
|
+
if (params.sender)
|
|
41
|
+
filters.push(`from/emailAddress/address eq '${params.sender}'`);
|
|
42
|
+
if (params.subject)
|
|
43
|
+
filters.push(`contains(subject,'${params.subject.replace(/'/g, "''")}')`);
|
|
44
|
+
if (filters.length > 0)
|
|
45
|
+
req = req.filter(filters.join(' and '));
|
|
46
|
+
}
|
|
47
|
+
const response = await req.get();
|
|
48
|
+
// Client-side post-filter when bodySearch was used with date/sender constraints
|
|
49
|
+
if (params.bodySearch && (params.startDate || params.endDate || params.sender)) {
|
|
50
|
+
const filtered = (response.value ?? []).filter((msg) => {
|
|
51
|
+
const received = new Date(msg['receivedDateTime']).getTime();
|
|
52
|
+
if (params.startDate && received < new Date(params.startDate).getTime())
|
|
53
|
+
return false;
|
|
54
|
+
if (params.endDate && received > new Date(params.endDate).getTime())
|
|
55
|
+
return false;
|
|
56
|
+
if (params.sender) {
|
|
57
|
+
const from = msg['from']
|
|
58
|
+
?.emailAddress?.address?.toLowerCase();
|
|
59
|
+
if (from !== params.sender.toLowerCase())
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
});
|
|
64
|
+
return { value: filtered };
|
|
65
|
+
}
|
|
66
|
+
return response;
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
throw mapGraphError(err, 'list_messages');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export async function getMessage(client, email, messageId, includeBody) {
|
|
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
|
|
78
|
+
.api(`/users/${email}/messages/${messageId}`)
|
|
79
|
+
.select(select)
|
|
80
|
+
.get();
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
throw mapGraphError(err, 'get_message');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export async function sendMessage(client, email, params) {
|
|
87
|
+
try {
|
|
88
|
+
const toRecipients = params.to.map((addr) => ({ emailAddress: { address: addr } }));
|
|
89
|
+
const ccRecipients = (params.cc ?? []).map((addr) => ({ emailAddress: { address: addr } }));
|
|
90
|
+
const bccRecipients = (params.bcc ?? []).map((addr) => ({ emailAddress: { address: addr } }));
|
|
91
|
+
await client.api(`/users/${email}/sendMail`).post({
|
|
92
|
+
message: {
|
|
93
|
+
subject: params.subject,
|
|
94
|
+
body: { contentType: params.bodyType === 'html' ? 'HTML' : 'Text', content: params.body },
|
|
95
|
+
toRecipients,
|
|
96
|
+
...(ccRecipients.length > 0 && { ccRecipients }),
|
|
97
|
+
...(bccRecipients.length > 0 && { bccRecipients }),
|
|
98
|
+
},
|
|
99
|
+
saveToSentItems: params.saveToSent,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
throw mapGraphError(err, 'send_message');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export async function replyToMessage(client, email, messageId, params) {
|
|
107
|
+
try {
|
|
108
|
+
const endpoint = params.replyAll
|
|
109
|
+
? `/users/${email}/messages/${messageId}/replyAll`
|
|
110
|
+
: `/users/${email}/messages/${messageId}/reply`;
|
|
111
|
+
await client.api(endpoint).post({
|
|
112
|
+
message: {},
|
|
113
|
+
comment: params.body,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
throw mapGraphError(err, 'reply_message');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export async function moveMessage(client, email, messageId, params) {
|
|
121
|
+
try {
|
|
122
|
+
return await client.api(`/users/${email}/messages/${messageId}/move`).post({
|
|
123
|
+
destinationId: params.destinationFolderId,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
throw mapGraphError(err, 'move_message');
|
|
128
|
+
}
|
|
129
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { loadConfig } from './config/loader.js';
|
|
5
|
+
import { TokenCache } from './auth/TokenCache.js';
|
|
6
|
+
import { AuthManager } from './auth/AuthManager.js';
|
|
7
|
+
import { registerAllTools } from './tools/registry.js';
|
|
8
|
+
import { logger } from './utils/logger.js';
|
|
9
|
+
async function main() {
|
|
10
|
+
// 1. Load and validate config — aborts if invalid
|
|
11
|
+
const config = loadConfig();
|
|
12
|
+
logger.info('Config loaded', { accounts: config.accounts.map((a) => a.id) });
|
|
13
|
+
// 2. Initialise MSAL clients (one per account, in-memory token cache)
|
|
14
|
+
const tokenCache = new TokenCache(config);
|
|
15
|
+
const auth = new AuthManager(tokenCache);
|
|
16
|
+
// 3. Create MCP server and register all tools
|
|
17
|
+
const server = new McpServer({
|
|
18
|
+
name: '@teamnetwork/m365-mcp-server',
|
|
19
|
+
version: '1.0.0',
|
|
20
|
+
});
|
|
21
|
+
registerAllTools(server, config, auth);
|
|
22
|
+
// 4. Connect via stdio transport (Claude Desktop / Claude Code)
|
|
23
|
+
const transport = new StdioServerTransport();
|
|
24
|
+
await server.connect(transport);
|
|
25
|
+
logger.info('M365 MCP server running on stdio');
|
|
26
|
+
}
|
|
27
|
+
main().catch((err) => {
|
|
28
|
+
process.stderr.write(`Fatal: ${err.message ?? String(err)}\n`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { ToolError } from '../utils/errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Asserts that the given mailbox is configured and is NOT readonly.
|
|
4
|
+
* Must be called at the top of every write-path tool handler, before any Graph call.
|
|
5
|
+
*/
|
|
6
|
+
export function assertWritable(config, accountId, mailbox) {
|
|
7
|
+
const account = config.accounts.find((a) => a.id === accountId);
|
|
8
|
+
if (!account) {
|
|
9
|
+
throw new ToolError(`Unknown account ID: "${accountId}". Check your config file.`, 'CONFIG_ERROR');
|
|
10
|
+
}
|
|
11
|
+
const mbConfig = account.mailboxes.find((m) => m.email.toLowerCase() === mailbox.toLowerCase());
|
|
12
|
+
if (!mbConfig) {
|
|
13
|
+
throw new ToolError(`Mailbox "${mailbox}" is not configured for account "${accountId}".`, 'CONFIG_ERROR');
|
|
14
|
+
}
|
|
15
|
+
if (mbConfig.readonly) {
|
|
16
|
+
throw new ToolError(`Mailbox "${mailbox}" is configured as read-only — write operations are not permitted.`, 'READONLY_VIOLATION');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Asserts that the given mailbox is configured (readable). Shared helper for read tools. */
|
|
20
|
+
export function assertMailboxConfigured(config, accountId, mailbox) {
|
|
21
|
+
const account = config.accounts.find((a) => a.id === accountId);
|
|
22
|
+
if (!account) {
|
|
23
|
+
throw new ToolError(`Unknown account ID: "${accountId}". Check your config file.`, 'CONFIG_ERROR');
|
|
24
|
+
}
|
|
25
|
+
const mbConfig = account.mailboxes.find((m) => m.email.toLowerCase() === mailbox.toLowerCase());
|
|
26
|
+
if (!mbConfig) {
|
|
27
|
+
throw new ToolError(`Mailbox "${mailbox}" is not configured for account "${accountId}".`, 'CONFIG_ERROR');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { CreateEventSchema } from '../schemas.js';
|
|
2
|
+
import { createGraphClient } from '../../graph/clientFactory.js';
|
|
3
|
+
import { createEvent } from '../../graph/calendar.js';
|
|
4
|
+
import { assertWritable } from '../../middleware/readonlyGuard.js';
|
|
5
|
+
import { errorResponse } from '../../utils/errors.js';
|
|
6
|
+
import { logger } from '../../utils/logger.js';
|
|
7
|
+
export function registerCreateEvent(server, config, auth) {
|
|
8
|
+
server.tool('create_event', 'Create a new calendar event. Requires the mailbox to be non-readonly.', CreateEventSchema.shape, async (params) => {
|
|
9
|
+
logger.info('create_event', { accountId: params.accountId, mailbox: params.mailbox });
|
|
10
|
+
try {
|
|
11
|
+
assertWritable(config, params.accountId, params.mailbox);
|
|
12
|
+
const token = await auth.getAccessToken(params.accountId);
|
|
13
|
+
const client = createGraphClient(token);
|
|
14
|
+
const result = await createEvent(client, params.mailbox, {
|
|
15
|
+
subject: params.subject,
|
|
16
|
+
bodyType: params.bodyType,
|
|
17
|
+
startTime: params.startTime,
|
|
18
|
+
endTime: params.endTime,
|
|
19
|
+
timeZone: params.timeZone,
|
|
20
|
+
isOnlineMeeting: params.isOnlineMeeting,
|
|
21
|
+
...(params.calendarId !== undefined && { calendarId: params.calendarId }),
|
|
22
|
+
...(params.body !== undefined && { body: params.body }),
|
|
23
|
+
...(params.location !== undefined && { location: params.location }),
|
|
24
|
+
...(params.attendees !== undefined && { attendees: params.attendees }),
|
|
25
|
+
});
|
|
26
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
logger.error('create_event failed', { accountId: params.accountId, mailbox: params.mailbox });
|
|
30
|
+
return errorResponse(err);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { GetEventSchema } from '../schemas.js';
|
|
2
|
+
import { createGraphClient } from '../../graph/clientFactory.js';
|
|
3
|
+
import { getEvent } from '../../graph/calendar.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 registerGetEvent(server, config, auth) {
|
|
8
|
+
server.tool('get_event', 'Get full details of a specific calendar event by ID.', GetEventSchema.shape, async (params) => {
|
|
9
|
+
logger.info('get_event', { accountId: params.accountId, mailbox: params.mailbox });
|
|
10
|
+
try {
|
|
11
|
+
assertMailboxConfigured(config, params.accountId, params.mailbox);
|
|
12
|
+
const token = await auth.getAccessToken(params.accountId);
|
|
13
|
+
const client = createGraphClient(token);
|
|
14
|
+
const result = await getEvent(client, params.mailbox, params.eventId);
|
|
15
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
logger.error('get_event failed', { accountId: params.accountId, mailbox: params.mailbox });
|
|
19
|
+
return errorResponse(err);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ListCalendarsSchema } from '../schemas.js';
|
|
2
|
+
import { createGraphClient } from '../../graph/clientFactory.js';
|
|
3
|
+
import { listCalendars } from '../../graph/calendar.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 registerListCalendars(server, config, auth) {
|
|
8
|
+
server.tool('list_calendars', 'List all calendars for a mailbox.', ListCalendarsSchema.shape, async (params) => {
|
|
9
|
+
logger.info('list_calendars', { accountId: params.accountId, mailbox: params.mailbox });
|
|
10
|
+
try {
|
|
11
|
+
assertMailboxConfigured(config, params.accountId, params.mailbox);
|
|
12
|
+
const token = await auth.getAccessToken(params.accountId);
|
|
13
|
+
const client = createGraphClient(token);
|
|
14
|
+
const result = await listCalendars(client, params.mailbox);
|
|
15
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
logger.error('list_calendars failed', { accountId: params.accountId, mailbox: params.mailbox });
|
|
19
|
+
return errorResponse(err);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ListEventsSchema } from '../schemas.js';
|
|
2
|
+
import { createGraphClient } from '../../graph/clientFactory.js';
|
|
3
|
+
import { listEvents } from '../../graph/calendar.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 registerListEvents(server, config, auth) {
|
|
8
|
+
server.tool('list_events', 'List calendar events within a date range. Recurring events are expanded into individual instances.', ListEventsSchema.shape, async (params) => {
|
|
9
|
+
logger.info('list_events', { accountId: params.accountId, mailbox: params.mailbox });
|
|
10
|
+
try {
|
|
11
|
+
assertMailboxConfigured(config, params.accountId, params.mailbox);
|
|
12
|
+
const token = await auth.getAccessToken(params.accountId);
|
|
13
|
+
const client = createGraphClient(token);
|
|
14
|
+
const result = await listEvents(client, params.mailbox, {
|
|
15
|
+
startDate: params.startDate,
|
|
16
|
+
endDate: params.endDate,
|
|
17
|
+
maxResults: params.maxResults,
|
|
18
|
+
...(params.calendarId !== undefined && { calendarId: params.calendarId }),
|
|
19
|
+
});
|
|
20
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
logger.error('list_events failed', { accountId: params.accountId, mailbox: params.mailbox });
|
|
24
|
+
return errorResponse(err);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { UpdateEventSchema } from '../schemas.js';
|
|
2
|
+
import { createGraphClient } from '../../graph/clientFactory.js';
|
|
3
|
+
import { updateEvent } from '../../graph/calendar.js';
|
|
4
|
+
import { assertWritable } from '../../middleware/readonlyGuard.js';
|
|
5
|
+
import { errorResponse } from '../../utils/errors.js';
|
|
6
|
+
import { logger } from '../../utils/logger.js';
|
|
7
|
+
export function registerUpdateEvent(server, config, auth) {
|
|
8
|
+
server.tool('update_event', 'Update an existing calendar event. Only provided fields are changed. Requires the mailbox to be non-readonly.', UpdateEventSchema.shape, async (params) => {
|
|
9
|
+
logger.info('update_event', { accountId: params.accountId, mailbox: params.mailbox });
|
|
10
|
+
try {
|
|
11
|
+
assertWritable(config, params.accountId, params.mailbox);
|
|
12
|
+
const token = await auth.getAccessToken(params.accountId);
|
|
13
|
+
const client = createGraphClient(token);
|
|
14
|
+
const result = await updateEvent(client, params.mailbox, params.eventId, {
|
|
15
|
+
...(params.subject !== undefined && { subject: params.subject }),
|
|
16
|
+
...(params.body !== undefined && { body: params.body }),
|
|
17
|
+
...(params.bodyType !== undefined && { bodyType: params.bodyType }),
|
|
18
|
+
...(params.startTime !== undefined && { startTime: params.startTime }),
|
|
19
|
+
...(params.endTime !== undefined && { endTime: params.endTime }),
|
|
20
|
+
...(params.timeZone !== undefined && { timeZone: params.timeZone }),
|
|
21
|
+
...(params.location !== undefined && { location: params.location }),
|
|
22
|
+
...(params.attendees !== undefined && { attendees: params.attendees }),
|
|
23
|
+
});
|
|
24
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
logger.error('update_event failed', { accountId: params.accountId, mailbox: params.mailbox });
|
|
28
|
+
return errorResponse(err);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { GetMessageSchema } from '../schemas.js';
|
|
2
|
+
import { createGraphClient } from '../../graph/clientFactory.js';
|
|
3
|
+
import { getMessage } from '../../graph/email.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 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) => {
|
|
9
|
+
logger.info('get_message', { accountId: params.accountId, mailbox: params.mailbox });
|
|
10
|
+
try {
|
|
11
|
+
assertMailboxConfigured(config, params.accountId, params.mailbox);
|
|
12
|
+
const token = await auth.getAccessToken(params.accountId);
|
|
13
|
+
const client = createGraphClient(token);
|
|
14
|
+
const result = await getMessage(client, params.mailbox, params.messageId, params.includeBody);
|
|
15
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
logger.error('get_message failed', { accountId: params.accountId, mailbox: params.mailbox });
|
|
19
|
+
return errorResponse(err);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ListFoldersSchema } from '../schemas.js';
|
|
2
|
+
import { createGraphClient } from '../../graph/clientFactory.js';
|
|
3
|
+
import { listFolders } from '../../graph/email.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 registerListFolders(server, config, auth) {
|
|
8
|
+
server.tool('list_folders', 'List mail folders for a mailbox. Provide parentFolderId to list child folders.', ListFoldersSchema.shape, async (params) => {
|
|
9
|
+
logger.info('list_folders', { accountId: params.accountId, mailbox: params.mailbox });
|
|
10
|
+
try {
|
|
11
|
+
assertMailboxConfigured(config, params.accountId, params.mailbox);
|
|
12
|
+
const token = await auth.getAccessToken(params.accountId);
|
|
13
|
+
const client = createGraphClient(token);
|
|
14
|
+
const result = await listFolders(client, params.mailbox, {
|
|
15
|
+
...(params.parentFolderId !== undefined && { parentFolderId: params.parentFolderId }),
|
|
16
|
+
});
|
|
17
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
logger.error('list_folders failed', { accountId: params.accountId, mailbox: params.mailbox });
|
|
21
|
+
return errorResponse(err);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ListMailboxesSchema } from '../schemas.js';
|
|
2
|
+
import { logger } from '../../utils/logger.js';
|
|
3
|
+
export function registerListMailboxes(server, config) {
|
|
4
|
+
server.tool('list_mailboxes', 'List all configured accounts and their mailboxes, including each mailbox\'s read-only status.', ListMailboxesSchema.shape, async (params) => {
|
|
5
|
+
logger.info('list_mailboxes', { accountId: params.accountId });
|
|
6
|
+
const accounts = params.accountId
|
|
7
|
+
? config.accounts.filter((a) => a.id === params.accountId)
|
|
8
|
+
: config.accounts;
|
|
9
|
+
const result = accounts.map((a) => ({
|
|
10
|
+
id: a.id,
|
|
11
|
+
displayName: a.displayName,
|
|
12
|
+
mailboxes: a.mailboxes.map((m) => ({
|
|
13
|
+
email: m.email,
|
|
14
|
+
displayName: m.displayName,
|
|
15
|
+
readonly: m.readonly,
|
|
16
|
+
})),
|
|
17
|
+
}));
|
|
18
|
+
return {
|
|
19
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { ListMessagesSchema } from '../schemas.js';
|
|
2
|
+
import { createGraphClient } from '../../graph/clientFactory.js';
|
|
3
|
+
import { listMessages } from '../../graph/email.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 registerListMessages(server, config, auth) {
|
|
8
|
+
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
|
+
logger.info('list_messages', {
|
|
10
|
+
accountId: params.accountId,
|
|
11
|
+
mailbox: params.mailbox,
|
|
12
|
+
folderId: params.folderId,
|
|
13
|
+
maxResults: params.maxResults,
|
|
14
|
+
});
|
|
15
|
+
try {
|
|
16
|
+
assertMailboxConfigured(config, params.accountId, params.mailbox);
|
|
17
|
+
const token = await auth.getAccessToken(params.accountId);
|
|
18
|
+
const client = createGraphClient(token);
|
|
19
|
+
const result = await listMessages(client, params.mailbox, {
|
|
20
|
+
folderId: params.folderId,
|
|
21
|
+
maxResults: params.maxResults,
|
|
22
|
+
...(params.startDate !== undefined && { startDate: params.startDate }),
|
|
23
|
+
...(params.endDate !== undefined && { endDate: params.endDate }),
|
|
24
|
+
...(params.subject !== undefined && { subject: params.subject }),
|
|
25
|
+
...(params.bodySearch !== undefined && { bodySearch: params.bodySearch }),
|
|
26
|
+
...(params.sender !== undefined && { sender: params.sender }),
|
|
27
|
+
});
|
|
28
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
logger.error('list_messages failed', { accountId: params.accountId, mailbox: params.mailbox });
|
|
32
|
+
return errorResponse(err);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { MoveMessageSchema } from '../schemas.js';
|
|
2
|
+
import { createGraphClient } from '../../graph/clientFactory.js';
|
|
3
|
+
import { moveMessage } from '../../graph/email.js';
|
|
4
|
+
import { assertWritable } from '../../middleware/readonlyGuard.js';
|
|
5
|
+
import { errorResponse } from '../../utils/errors.js';
|
|
6
|
+
import { logger } from '../../utils/logger.js';
|
|
7
|
+
export function registerMoveMessage(server, config, auth) {
|
|
8
|
+
server.tool('move_message', 'Move an email message to a different folder. Requires the mailbox to be non-readonly.', MoveMessageSchema.shape, async (params) => {
|
|
9
|
+
logger.info('move_message', { accountId: params.accountId, mailbox: params.mailbox });
|
|
10
|
+
try {
|
|
11
|
+
assertWritable(config, params.accountId, params.mailbox);
|
|
12
|
+
const token = await auth.getAccessToken(params.accountId);
|
|
13
|
+
const client = createGraphClient(token);
|
|
14
|
+
const result = await moveMessage(client, params.mailbox, params.messageId, {
|
|
15
|
+
destinationFolderId: params.destinationFolderId,
|
|
16
|
+
});
|
|
17
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
logger.error('move_message failed', { accountId: params.accountId, mailbox: params.mailbox });
|
|
21
|
+
return errorResponse(err);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { ReplyMessageSchema } from '../schemas.js';
|
|
2
|
+
import { createGraphClient } from '../../graph/clientFactory.js';
|
|
3
|
+
import { replyToMessage } from '../../graph/email.js';
|
|
4
|
+
import { assertWritable } from '../../middleware/readonlyGuard.js';
|
|
5
|
+
import { errorResponse } from '../../utils/errors.js';
|
|
6
|
+
import { logger } from '../../utils/logger.js';
|
|
7
|
+
export function registerReplyMessage(server, config, auth) {
|
|
8
|
+
server.tool('reply_message', 'Reply to an email message. Use replyAll to reply to all recipients. Requires the mailbox to be non-readonly.', ReplyMessageSchema.shape, async (params) => {
|
|
9
|
+
logger.info('reply_message', { accountId: params.accountId, mailbox: params.mailbox, replyAll: params.replyAll });
|
|
10
|
+
try {
|
|
11
|
+
assertWritable(config, params.accountId, params.mailbox);
|
|
12
|
+
const token = await auth.getAccessToken(params.accountId);
|
|
13
|
+
const client = createGraphClient(token);
|
|
14
|
+
await replyToMessage(client, params.mailbox, params.messageId, {
|
|
15
|
+
body: params.body,
|
|
16
|
+
bodyType: params.bodyType,
|
|
17
|
+
replyAll: params.replyAll,
|
|
18
|
+
});
|
|
19
|
+
return { content: [{ type: 'text', text: `Reply sent successfully.` }] };
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
logger.error('reply_message failed', { accountId: params.accountId, mailbox: params.mailbox });
|
|
23
|
+
return errorResponse(err);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { SendMessageSchema } from '../schemas.js';
|
|
2
|
+
import { createGraphClient } from '../../graph/clientFactory.js';
|
|
3
|
+
import { sendMessage } from '../../graph/email.js';
|
|
4
|
+
import { assertWritable } from '../../middleware/readonlyGuard.js';
|
|
5
|
+
import { errorResponse } from '../../utils/errors.js';
|
|
6
|
+
import { logger } from '../../utils/logger.js';
|
|
7
|
+
export function registerSendMessage(server, config, auth) {
|
|
8
|
+
server.tool('send_message', 'Send an email from a configured mailbox. Requires the mailbox to be non-readonly.', SendMessageSchema.shape, async (params) => {
|
|
9
|
+
logger.info('send_message', { accountId: params.accountId, mailbox: params.mailbox });
|
|
10
|
+
try {
|
|
11
|
+
assertWritable(config, params.accountId, params.mailbox);
|
|
12
|
+
const token = await auth.getAccessToken(params.accountId);
|
|
13
|
+
const client = createGraphClient(token);
|
|
14
|
+
await sendMessage(client, params.mailbox, {
|
|
15
|
+
to: params.to,
|
|
16
|
+
subject: params.subject,
|
|
17
|
+
body: params.body,
|
|
18
|
+
bodyType: params.bodyType,
|
|
19
|
+
saveToSent: params.saveToSent,
|
|
20
|
+
...(params.cc !== undefined && { cc: params.cc }),
|
|
21
|
+
...(params.bcc !== undefined && { bcc: params.bcc }),
|
|
22
|
+
});
|
|
23
|
+
return { content: [{ type: 'text', text: 'Message sent successfully.' }] };
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
logger.error('send_message failed', { accountId: params.accountId, mailbox: params.mailbox });
|
|
27
|
+
return errorResponse(err);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Email tools
|
|
2
|
+
import { registerListMailboxes } from './email/listMailboxes.js';
|
|
3
|
+
import { registerListFolders } from './email/listFolders.js';
|
|
4
|
+
import { registerListMessages } from './email/listMessages.js';
|
|
5
|
+
import { registerGetMessage } from './email/getMessage.js';
|
|
6
|
+
import { registerSendMessage } from './email/sendMessage.js';
|
|
7
|
+
import { registerReplyMessage } from './email/replyMessage.js';
|
|
8
|
+
import { registerMoveMessage } from './email/moveMessage.js';
|
|
9
|
+
// Calendar tools
|
|
10
|
+
import { registerListCalendars } from './calendar/listCalendars.js';
|
|
11
|
+
import { registerListEvents } from './calendar/listEvents.js';
|
|
12
|
+
import { registerGetEvent } from './calendar/getEvent.js';
|
|
13
|
+
import { registerCreateEvent } from './calendar/createEvent.js';
|
|
14
|
+
import { registerUpdateEvent } from './calendar/updateEvent.js';
|
|
15
|
+
function registerEmailTools(server, config, auth) {
|
|
16
|
+
registerListMailboxes(server, config);
|
|
17
|
+
registerListFolders(server, config, auth);
|
|
18
|
+
registerListMessages(server, config, auth);
|
|
19
|
+
registerGetMessage(server, config, auth);
|
|
20
|
+
registerSendMessage(server, config, auth);
|
|
21
|
+
registerReplyMessage(server, config, auth);
|
|
22
|
+
registerMoveMessage(server, config, auth);
|
|
23
|
+
}
|
|
24
|
+
function registerCalendarTools(server, config, auth) {
|
|
25
|
+
registerListCalendars(server, config, auth);
|
|
26
|
+
registerListEvents(server, config, auth);
|
|
27
|
+
registerGetEvent(server, config, auth);
|
|
28
|
+
registerCreateEvent(server, config, auth);
|
|
29
|
+
registerUpdateEvent(server, config, auth);
|
|
30
|
+
}
|
|
31
|
+
export function registerAllTools(server, config, auth) {
|
|
32
|
+
registerEmailTools(server, config, auth);
|
|
33
|
+
registerCalendarTools(server, config, auth);
|
|
34
|
+
// Future: registerTeamsTools(server, config, auth);
|
|
35
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
// ── Common ─────────────────────────────────────────────────────────────────
|
|
3
|
+
const AccountMailbox = z.object({
|
|
4
|
+
accountId: z.string().describe('Account ID as defined in the config file'),
|
|
5
|
+
mailbox: z.string().email().describe('Mailbox email address as defined in the config file'),
|
|
6
|
+
});
|
|
7
|
+
// ── Email tools ────────────────────────────────────────────────────────────
|
|
8
|
+
export const ListMailboxesSchema = z.object({
|
|
9
|
+
accountId: z.string().optional().describe('Filter to a specific account ID. Omit to list all accounts and their mailboxes.'),
|
|
10
|
+
});
|
|
11
|
+
export const ListFoldersSchema = AccountMailbox.extend({
|
|
12
|
+
parentFolderId: z.string().optional().describe('ID of the parent folder to list child folders from. Omit for top-level folders.'),
|
|
13
|
+
});
|
|
14
|
+
export const ListMessagesSchema = AccountMailbox.extend({
|
|
15
|
+
folderId: z.string().default('Inbox').describe('Folder ID or well-known name (e.g. "Inbox", "SentItems", "Drafts")'),
|
|
16
|
+
startDate: z.string().datetime().optional().describe('Filter messages received on or after this ISO 8601 datetime'),
|
|
17
|
+
endDate: z.string().datetime().optional().describe('Filter messages received on or before this ISO 8601 datetime'),
|
|
18
|
+
subject: z.string().optional().describe('Filter messages whose subject contains this string (ignored when bodySearch is set)'),
|
|
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
|
+
sender: z.string().email().optional().describe('Filter messages from this sender email address'),
|
|
21
|
+
maxResults: z.number().int().min(1).max(100).default(25).describe('Maximum number of messages to return (1–100)'),
|
|
22
|
+
});
|
|
23
|
+
export const GetMessageSchema = AccountMailbox.extend({
|
|
24
|
+
messageId: z.string().describe('The message ID'),
|
|
25
|
+
includeBody: z.boolean().default(true).describe('Whether to include the full message body in the response'),
|
|
26
|
+
});
|
|
27
|
+
export const SendMessageSchema = AccountMailbox.extend({
|
|
28
|
+
to: z.array(z.string().email()).min(1).describe('List of recipient email addresses'),
|
|
29
|
+
cc: z.array(z.string().email()).optional().describe('CC recipients'),
|
|
30
|
+
bcc: z.array(z.string().email()).optional().describe('BCC recipients'),
|
|
31
|
+
subject: z.string().min(1).describe('Message subject'),
|
|
32
|
+
body: z.string().describe('Message body content'),
|
|
33
|
+
bodyType: z.enum(['text', 'html']).default('text').describe('Body content type'),
|
|
34
|
+
saveToSent: z.boolean().default(true).describe('Whether to save the message to Sent Items'),
|
|
35
|
+
});
|
|
36
|
+
export const ReplyMessageSchema = AccountMailbox.extend({
|
|
37
|
+
messageId: z.string().describe('The ID of the message to reply to'),
|
|
38
|
+
body: z.string().describe('Reply body content'),
|
|
39
|
+
bodyType: z.enum(['text', 'html']).default('text'),
|
|
40
|
+
replyAll: z.boolean().default(false).describe('If true, reply to all recipients; otherwise reply only to sender'),
|
|
41
|
+
});
|
|
42
|
+
export const MoveMessageSchema = AccountMailbox.extend({
|
|
43
|
+
messageId: z.string().describe('The ID of the message to move'),
|
|
44
|
+
destinationFolderId: z.string().describe('The destination folder ID or well-known name (e.g. "DeletedItems")'),
|
|
45
|
+
});
|
|
46
|
+
// ── Calendar tools ─────────────────────────────────────────────────────────
|
|
47
|
+
export const ListCalendarsSchema = AccountMailbox;
|
|
48
|
+
export const ListEventsSchema = AccountMailbox.extend({
|
|
49
|
+
calendarId: z.string().optional().describe('Calendar ID. Omit to use the default calendar.'),
|
|
50
|
+
startDate: z.string().datetime().describe('Start of the date range (ISO 8601). Recurring events are expanded within this window.'),
|
|
51
|
+
endDate: z.string().datetime().describe('End of the date range (ISO 8601)'),
|
|
52
|
+
maxResults: z.number().int().min(1).max(100).default(25),
|
|
53
|
+
});
|
|
54
|
+
export const GetEventSchema = AccountMailbox.extend({
|
|
55
|
+
eventId: z.string().describe('The event ID'),
|
|
56
|
+
calendarId: z.string().optional().describe('Calendar ID. Omit to search across all calendars.'),
|
|
57
|
+
});
|
|
58
|
+
export const CreateEventSchema = AccountMailbox.extend({
|
|
59
|
+
calendarId: z.string().optional().describe('Calendar ID. Omit to create in the default calendar.'),
|
|
60
|
+
subject: z.string().min(1).describe('Event subject / title'),
|
|
61
|
+
body: z.string().optional().describe('Event description / body'),
|
|
62
|
+
bodyType: z.enum(['text', 'html']).default('text'),
|
|
63
|
+
startTime: z.string().datetime().describe('Event start time (ISO 8601 with timezone offset)'),
|
|
64
|
+
endTime: z.string().datetime().describe('Event end time (ISO 8601 with timezone offset)'),
|
|
65
|
+
timeZone: z.string().default('UTC').describe('IANA timezone name, e.g. "Pacific/Auckland"'),
|
|
66
|
+
location: z.string().optional().describe('Location display name'),
|
|
67
|
+
attendees: z.array(z.string().email()).optional().describe('List of attendee email addresses'),
|
|
68
|
+
isOnlineMeeting: z.boolean().default(false).describe('Whether to create an online meeting link'),
|
|
69
|
+
});
|
|
70
|
+
export const UpdateEventSchema = AccountMailbox.extend({
|
|
71
|
+
eventId: z.string().describe('The ID of the event to update'),
|
|
72
|
+
subject: z.string().optional(),
|
|
73
|
+
body: z.string().optional(),
|
|
74
|
+
bodyType: z.enum(['text', 'html']).optional(),
|
|
75
|
+
startTime: z.string().datetime().optional(),
|
|
76
|
+
endTime: z.string().datetime().optional(),
|
|
77
|
+
timeZone: z.string().optional(),
|
|
78
|
+
location: z.string().optional(),
|
|
79
|
+
attendees: z.array(z.string().email()).optional(),
|
|
80
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export class ToolError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
retryable;
|
|
4
|
+
constructor(message, code, retryable = false) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.retryable = retryable;
|
|
8
|
+
this.name = 'ToolError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function mapGraphError(err, context) {
|
|
12
|
+
if (err instanceof ToolError)
|
|
13
|
+
return err;
|
|
14
|
+
const status = err?.['statusCode'];
|
|
15
|
+
const msg = err?.message ?? String(err);
|
|
16
|
+
// Never include response body in mapped errors — it may contain email/calendar content
|
|
17
|
+
switch (status) {
|
|
18
|
+
case 400:
|
|
19
|
+
return new ToolError(`Bad request to Graph API (${context}): ${msg}`, 'GRAPH_BAD_REQUEST');
|
|
20
|
+
case 401:
|
|
21
|
+
return new ToolError(`Authentication failed (${context}) — verify tenant ID, client ID, and client secret`, 'GRAPH_UNAUTHORIZED');
|
|
22
|
+
case 403:
|
|
23
|
+
return new ToolError(`Access denied (${context}) — verify ApplicationAccessPolicy is configured and app permissions are admin-consented`, 'GRAPH_FORBIDDEN');
|
|
24
|
+
case 404:
|
|
25
|
+
return new ToolError(`Resource not found (${context})`, 'GRAPH_NOT_FOUND');
|
|
26
|
+
case 429: {
|
|
27
|
+
const headers = err?.['headers'];
|
|
28
|
+
const retryAfter = headers?.['retry-after'] ?? 'unknown';
|
|
29
|
+
return new ToolError(`Graph API rate limit hit (${context}). Retry after ${retryAfter}s`, 'GRAPH_RATE_LIMIT', true);
|
|
30
|
+
}
|
|
31
|
+
case 503:
|
|
32
|
+
case 504:
|
|
33
|
+
return new ToolError(`Graph API service unavailable (${context}) — try again shortly`, 'GRAPH_UNAVAILABLE', true);
|
|
34
|
+
default:
|
|
35
|
+
if (status !== undefined) {
|
|
36
|
+
return new ToolError(`Graph API error ${status} (${context})`, 'GRAPH_ERROR');
|
|
37
|
+
}
|
|
38
|
+
return new ToolError(`Unexpected error (${context}): ${msg}`, 'UNKNOWN');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Format a tool error response for the MCP SDK */
|
|
42
|
+
export function errorResponse(err) {
|
|
43
|
+
const toolErr = err instanceof ToolError ? err : mapGraphError(err, 'unknown');
|
|
44
|
+
return {
|
|
45
|
+
content: [{ type: 'text', text: toolErr.message }],
|
|
46
|
+
isError: true,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Structured logger with scrubbing of secrets, tokens, and email body content.
|
|
2
|
+
// Only tool name, account ID, mailbox address, status codes, and timestamps are safe to log.
|
|
3
|
+
const SCRUB_PATTERNS = [
|
|
4
|
+
/clientSecret['":\s]+[^\s,}"']+/gi,
|
|
5
|
+
/Bearer\s+[A-Za-z0-9\-_.]+/gi,
|
|
6
|
+
/accessToken['":\s]+[A-Za-z0-9\-_.]+/gi,
|
|
7
|
+
/"content"\s*:\s*"[^"]{50,}"/gi, // long content strings (likely email body)
|
|
8
|
+
/"body"\s*:\s*\{[^}]{20,}\}/gi, // body objects
|
|
9
|
+
];
|
|
10
|
+
function scrub(value) {
|
|
11
|
+
let result = value;
|
|
12
|
+
for (const pattern of SCRUB_PATTERNS) {
|
|
13
|
+
result = result.replace(pattern, '[REDACTED]');
|
|
14
|
+
}
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
function format(level, message, meta) {
|
|
18
|
+
const ts = new Date().toISOString();
|
|
19
|
+
const metaStr = meta ? ' ' + scrub(JSON.stringify(meta)) : '';
|
|
20
|
+
return `[${ts}] [${level}] ${message}${metaStr}`;
|
|
21
|
+
}
|
|
22
|
+
export const logger = {
|
|
23
|
+
info(message, meta) {
|
|
24
|
+
process.stderr.write(format('INFO', message, meta) + '\n');
|
|
25
|
+
},
|
|
26
|
+
warn(message, meta) {
|
|
27
|
+
process.stderr.write(format('WARN', message, meta) + '\n');
|
|
28
|
+
},
|
|
29
|
+
error(message, meta) {
|
|
30
|
+
process.stderr.write(format('ERROR', message, meta) + '\n');
|
|
31
|
+
},
|
|
32
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@teamnetwork/m365-mcp-server",
|
|
3
|
+
"version": "1.0.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"m365-mcp": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"config.example.json"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"prepare": "npm run build",
|
|
17
|
+
"start": "node dist/index.js",
|
|
18
|
+
"dev": "tsx watch src/index.ts"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"mcp",
|
|
22
|
+
"model-context-protocol",
|
|
23
|
+
"microsoft-365",
|
|
24
|
+
"m365",
|
|
25
|
+
"outlook",
|
|
26
|
+
"email",
|
|
27
|
+
"calendar",
|
|
28
|
+
"microsoft-graph",
|
|
29
|
+
"claude",
|
|
30
|
+
"ai"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18.0.0"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@azure/msal-node": "^2.16.0",
|
|
38
|
+
"@microsoft/microsoft-graph-client": "^3.0.7",
|
|
39
|
+
"@modelcontextprotocol/sdk": "^1.10.0",
|
|
40
|
+
"zod": "^3.23.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^20.0.0",
|
|
44
|
+
"tsx": "^4.10.0",
|
|
45
|
+
"typescript": "^5.4.0"
|
|
46
|
+
}
|
|
47
|
+
}
|