@mhdd_24/m365-mcp 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/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 mhdd_24
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,174 @@
1
+ # @mhdd_24/m365-mcp
2
+
3
+ Custom MCP server for **Microsoft Outlook** and **Microsoft Teams** via the **Microsoft Graph API**. Built with the same architecture as `@mhdd_24/timelog-mcp`.
4
+
5
+ Use it from Cursor, Claude Desktop, or any MCP-compatible client.
6
+
7
+ ---
8
+
9
+ ## Tools (10)
10
+
11
+ | Tool | Domain | Description |
12
+ |------|--------|-------------|
13
+ | `whoami` | Profile | Signed-in user profile |
14
+ | `list_inbox` | Outlook | Recent inbox messages |
15
+ | `read_email` | Outlook | Read a message by ID |
16
+ | `send_email` | Outlook | Send email |
17
+ | `list_calendar` | Outlook | Calendar events for a date range |
18
+ | `list_teams` | Teams | Joined teams |
19
+ | `list_teams_chats` | Teams | Direct/group chats |
20
+ | `send_teams_chat` | Teams | Message a chat |
21
+ | `list_channels` | Teams | Channels in a team |
22
+ | `send_channel_message` | Teams | Post to a channel |
23
+
24
+ ---
25
+
26
+ ## Prerequisites
27
+
28
+ | Requirement | Notes |
29
+ |-------------|--------|
30
+ | **Node.js 18+** | Native `fetch` |
31
+ | **Azure App Registration** | Public client (device code flow) |
32
+ | **Graph API permissions** | See below |
33
+ | **Admin consent** | May be required in your org |
34
+
35
+ ### Azure App Registration
36
+
37
+ 1. [Azure Portal](https://portal.azure.com) → **Entra ID** → **App registrations** → **New registration**
38
+ 2. Name: `Cursor M365 MCP`
39
+ 3. Supported account types: **Single tenant** (recommended)
40
+ 4. No redirect URI required for device code flow
41
+ 5. **Authentication** → Enable **Allow public client flows** → Yes
42
+ 6. **API permissions** → Microsoft Graph → **Delegated**:
43
+
44
+ | Permission | Purpose |
45
+ |--------------|---------|
46
+ | `User.Read` | Profile |
47
+ | `Mail.Read` | Read inbox |
48
+ | `Mail.Send` | Send email |
49
+ | `Calendars.Read` | Calendar |
50
+ | `Chat.Read`, `Chat.ReadWrite`, `ChatMessage.Send` | Teams chats |
51
+ | `Team.ReadBasic.All` | List teams |
52
+ | `Channel.ReadBasic.All`, `ChannelMessage.Send` | Channels |
53
+
54
+ 7. **Grant admin consent** if required
55
+ 8. Copy **Application (client) ID** and **Directory (tenant) ID**
56
+
57
+ ---
58
+
59
+ ## Install
60
+
61
+ ```bash
62
+ git clone https://github.com/Mhdd-24/M365-MCP.git
63
+ cd m365-mcp
64
+ npm install
65
+ npm run build
66
+ ```
67
+
68
+ Or after publish:
69
+
70
+ ```bash
71
+ npx @mhdd_24/m365-mcp
72
+ ```
73
+
74
+ ---
75
+
76
+ ## Configure Cursor
77
+
78
+ Add to `~/.cursor/mcp.json`:
79
+
80
+ ```json
81
+ {
82
+ "mcpServers": {
83
+ "m365": {
84
+ "command": "npx",
85
+ "args": ["-y", "@mhdd_24/m365-mcp"],
86
+ "env": {
87
+ "M365_CLIENT_ID": "<azure-app-client-id>",
88
+ "M365_TENANT_ID": "<azure-tenant-id>"
89
+ }
90
+ }
91
+ }
92
+ }
93
+ ```
94
+
95
+ **Local development:**
96
+
97
+ ```json
98
+ "command": "node",
99
+ "args": ["C:/path/to/m365-mcp/dist/index.js"]
100
+ ```
101
+
102
+ ---
103
+
104
+ ## First-time sign-in
105
+
106
+ On first tool call, the server prints a **device code** URL to stderr. Open the link, enter the code, and sign in with your work account.
107
+
108
+ Or sign in ahead of time:
109
+
110
+ ```bash
111
+ cp .env.example .env
112
+ # fill M365_CLIENT_ID and M365_TENANT_ID
113
+ npm run login
114
+ ```
115
+
116
+ Token cache: `~/.m365-mcp/msal-cache.json`
117
+
118
+ ---
119
+
120
+ ## Environment variables
121
+
122
+ | Variable | Required | Description |
123
+ |----------|----------|-------------|
124
+ | `M365_CLIENT_ID` | Yes | Azure app client ID |
125
+ | `M365_TENANT_ID` | Yes | Azure tenant ID |
126
+ | `M365_GRAPH_BASE_URL` | No | Default `https://graph.microsoft.com/v1.0` |
127
+ | `M365_TOKEN_CACHE_PATH` | No | Custom token cache file path |
128
+
129
+ ---
130
+
131
+ ## Example prompts
132
+
133
+ **Outlook**
134
+
135
+ > List my unread emails.
136
+
137
+ > Send an email to colleague@company.com — subject "Sprint review", body "Meeting at 3pm."
138
+
139
+ > What's on my calendar today?
140
+
141
+ **Teams**
142
+
143
+ > List my Teams.
144
+
145
+ > Send a Teams chat message: "PR is ready for review" to chat ID …
146
+
147
+ > Post "Deploy complete" to the General channel in team …
148
+
149
+ ---
150
+
151
+ ## Project structure
152
+
153
+ ```
154
+ m365-mcp/
155
+ ├── src/
156
+ │ ├── index.ts
157
+ │ ├── env.ts
158
+ │ ├── config/m365.config.ts
159
+ │ ├── interfaces/graph.ts
160
+ │ ├── services/
161
+ │ │ ├── authService.ts ← OAuth device code + MSAL cache
162
+ │ │ ├── graphClient.ts ← Graph HTTP wrapper
163
+ │ │ └── graphService.ts ← Mail, calendar, Teams APIs
164
+ │ ├── tools/ ← MCP tool handlers
165
+ │ └── scripts/login.ts
166
+ ├── package.json
167
+ └── README.md
168
+ ```
169
+
170
+ ---
171
+
172
+ ## License
173
+
174
+ ISC
@@ -0,0 +1,129 @@
1
+ export const M365 = {
2
+ SERVER: {
3
+ NAME: '@mhdd_24/m365-mcp',
4
+ VERSION: '1.0.0',
5
+ STARTUP_MESSAGE: 'Microsoft 365 MCP Server Started',
6
+ FATAL_PREFIX: 'Fatal error:',
7
+ },
8
+ GRAPH: {
9
+ DEFAULT_BASE_URL: 'https://graph.microsoft.com/v1.0',
10
+ AUTHORITY_TEMPLATE: 'https://login.microsoftonline.com/{tenant}',
11
+ SCOPES: [
12
+ 'User.Read',
13
+ 'Mail.Read',
14
+ 'Mail.Send',
15
+ 'Calendars.Read',
16
+ 'Chat.Read',
17
+ 'Chat.ReadWrite',
18
+ 'ChatMessage.Send',
19
+ 'Team.ReadBasic.All',
20
+ 'Channel.ReadBasic.All',
21
+ 'ChannelMessage.Send',
22
+ ],
23
+ },
24
+ CACHE: {
25
+ DEFAULT_DIR: '.m365-mcp',
26
+ DEFAULT_FILE: 'msal-cache.json',
27
+ },
28
+ HTTP: {
29
+ SUCCESS_MIN: 200,
30
+ SUCCESS_MAX: 299,
31
+ ERROR_BODY_SLICE: 500,
32
+ },
33
+ LIMITS: {
34
+ DEFAULT_TOP: 25,
35
+ MAX_TOP: 50,
36
+ SUBJECT_SLICE: 120,
37
+ PREVIEW_SLICE: 200,
38
+ },
39
+ ENV: {
40
+ CLIENT_ID_KEYS: ['M365_CLIENT_ID', 'm365ClientId', 'MS365_MCP_CLIENT_ID'],
41
+ TENANT_ID_KEYS: ['M365_TENANT_ID', 'm365TenantId', 'MS365_MCP_TENANT_ID'],
42
+ GRAPH_BASE_URL_KEYS: ['M365_GRAPH_BASE_URL', 'm365GraphBaseUrl'],
43
+ TOKEN_CACHE_PATH_KEYS: ['M365_TOKEN_CACHE_PATH', 'm365TokenCachePath'],
44
+ },
45
+ TOOLS: {
46
+ WHOAMI: {
47
+ NAME: 'whoami',
48
+ DESCRIPTION: 'Show the signed-in Microsoft 365 user profile (display name, email, job title).',
49
+ FAILURE: 'whoami failed',
50
+ },
51
+ LIST_INBOX: {
52
+ NAME: 'list_inbox',
53
+ DESCRIPTION: 'List recent emails from the signed-in user inbox. Optionally filter unread only.',
54
+ TOP_DESCRIPTION: 'Maximum messages to return (1-50). Default 25.',
55
+ UNREAD_ONLY_DESCRIPTION: 'If true, return only unread messages.',
56
+ FAILURE: 'list_inbox failed',
57
+ EMPTY: 'No messages found.',
58
+ },
59
+ READ_EMAIL: {
60
+ NAME: 'read_email',
61
+ DESCRIPTION: 'Read a single email by message ID (from list_inbox).',
62
+ MESSAGE_ID_DESCRIPTION: 'Graph message ID.',
63
+ FAILURE: 'read_email failed',
64
+ },
65
+ SEND_EMAIL: {
66
+ NAME: 'send_email',
67
+ DESCRIPTION: 'Send an email from the signed-in user mailbox.',
68
+ TO_DESCRIPTION: 'Recipient email address.',
69
+ SUBJECT_DESCRIPTION: 'Email subject.',
70
+ BODY_DESCRIPTION: 'Plain-text email body.',
71
+ FAILURE: 'send_email failed',
72
+ SUCCESS: 'Email sent successfully.',
73
+ },
74
+ LIST_CALENDAR: {
75
+ NAME: 'list_calendar',
76
+ DESCRIPTION: 'List calendar events for a date range (defaults to today).',
77
+ START_DESCRIPTION: 'Start datetime ISO 8601, e.g. 2026-07-03T00:00:00.',
78
+ END_DESCRIPTION: 'End datetime ISO 8601, e.g. 2026-07-03T23:59:59.',
79
+ TOP_DESCRIPTION: 'Maximum events to return (1-50). Default 25.',
80
+ FAILURE: 'list_calendar failed',
81
+ EMPTY: 'No events found.',
82
+ },
83
+ LIST_TEAMS: {
84
+ NAME: 'list_teams',
85
+ DESCRIPTION: 'List Microsoft Teams the signed-in user has joined.',
86
+ FAILURE: 'list_teams failed',
87
+ EMPTY: 'No teams found.',
88
+ },
89
+ LIST_TEAMS_CHATS: {
90
+ NAME: 'list_teams_chats',
91
+ DESCRIPTION: 'List Teams chats (1:1 and group) for the signed-in user.',
92
+ TOP_DESCRIPTION: 'Maximum chats to return (1-50). Default 25.',
93
+ FAILURE: 'list_teams_chats failed',
94
+ EMPTY: 'No chats found.',
95
+ },
96
+ SEND_TEAMS_CHAT: {
97
+ NAME: 'send_teams_chat',
98
+ DESCRIPTION: 'Send a message to a Teams chat by chat ID (from list_teams_chats).',
99
+ CHAT_ID_DESCRIPTION: 'Teams chat ID.',
100
+ MESSAGE_DESCRIPTION: 'Plain-text message content.',
101
+ FAILURE: 'send_teams_chat failed',
102
+ SUCCESS: 'Chat message sent.',
103
+ },
104
+ LIST_CHANNELS: {
105
+ NAME: 'list_channels',
106
+ DESCRIPTION: 'List channels in a Microsoft Team by team ID (from list_teams).',
107
+ TEAM_ID_DESCRIPTION: 'Microsoft Team ID.',
108
+ FAILURE: 'list_channels failed',
109
+ EMPTY: 'No channels found.',
110
+ },
111
+ SEND_CHANNEL_MESSAGE: {
112
+ NAME: 'send_channel_message',
113
+ DESCRIPTION: 'Post a message to a Teams channel.',
114
+ TEAM_ID_DESCRIPTION: 'Microsoft Team ID.',
115
+ CHANNEL_ID_DESCRIPTION: 'Channel ID (from list_channels).',
116
+ MESSAGE_DESCRIPTION: 'Plain-text message content.',
117
+ FAILURE: 'send_channel_message failed',
118
+ SUCCESS: 'Channel message posted.',
119
+ },
120
+ },
121
+ MESSAGES: {
122
+ MISSING_CLIENT_ID: 'Missing M365_CLIENT_ID. Register an Azure app and set the client ID in MCP env or .env.',
123
+ MISSING_TENANT_ID: 'Missing M365_TENANT_ID. Set your Azure tenant ID in MCP env or .env.',
124
+ GENERIC_ERROR_PREFIX: 'Error:',
125
+ GRAPH_FAILED: 'Graph API request failed:',
126
+ NETWORK_ERROR: 'Network error calling Graph API:',
127
+ NOT_AUTHENTICATED: 'Not authenticated. Run `npm run login` in the m365-mcp folder or trigger any tool to sign in via device code.',
128
+ },
129
+ };
package/dist/env.js ADDED
@@ -0,0 +1,26 @@
1
+ import dotenv from 'dotenv';
2
+ import { M365 } from './config/m365.config.js';
3
+ dotenv.config();
4
+ function readEnv(keys) {
5
+ for (const key of keys) {
6
+ const value = process.env[key];
7
+ if (value?.trim()) {
8
+ return value.trim();
9
+ }
10
+ }
11
+ return undefined;
12
+ }
13
+ export const env = {
14
+ CLIENT_ID: readEnv(M365.ENV.CLIENT_ID_KEYS) ?? '',
15
+ TENANT_ID: readEnv(M365.ENV.TENANT_ID_KEYS) ?? '',
16
+ GRAPH_BASE_URL: readEnv(M365.ENV.GRAPH_BASE_URL_KEYS) ?? M365.GRAPH.DEFAULT_BASE_URL,
17
+ TOKEN_CACHE_PATH: readEnv(M365.ENV.TOKEN_CACHE_PATH_KEYS) ?? '',
18
+ };
19
+ export function validateEnv() {
20
+ if (!env.CLIENT_ID) {
21
+ throw new Error(M365.MESSAGES.MISSING_CLIENT_ID);
22
+ }
23
+ if (!env.TENANT_ID) {
24
+ throw new Error(M365.MESSAGES.MISSING_TENANT_ID);
25
+ }
26
+ }
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
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 { M365 } from './config/m365.config.js';
5
+ import { validateEnv } from './env.js';
6
+ import { registerTools } from './tools/index.js';
7
+ validateEnv();
8
+ const server = new McpServer({
9
+ name: M365.SERVER.NAME,
10
+ version: M365.SERVER.VERSION,
11
+ });
12
+ registerTools(server);
13
+ async function main() {
14
+ const transport = new StdioServerTransport();
15
+ await server.connect(transport);
16
+ console.error(M365.SERVER.STARTUP_MESSAGE);
17
+ }
18
+ main().catch((error) => {
19
+ console.error(M365.SERVER.FATAL_PREFIX, error);
20
+ process.exit(1);
21
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import { validateEnv } from '../env.js';
2
+ import { getAccessToken } from '../services/authService.js';
3
+ import { getMe } from '../services/graphService.js';
4
+ validateEnv();
5
+ async function main() {
6
+ await getAccessToken();
7
+ const user = await getMe();
8
+ console.log('Signed in successfully.');
9
+ console.log(`User: ${user.displayName ?? user.userPrincipalName ?? user.id}`);
10
+ }
11
+ main().catch((error) => {
12
+ console.error('Login failed:', error instanceof Error ? error.message : error);
13
+ process.exit(1);
14
+ });
@@ -0,0 +1,81 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { PublicClientApplication } from '@azure/msal-node';
5
+ import { M365 } from '../config/m365.config.js';
6
+ import { env } from '../env.js';
7
+ let pca;
8
+ let authInProgress;
9
+ export function getTokenCachePath() {
10
+ if (env.TOKEN_CACHE_PATH) {
11
+ return env.TOKEN_CACHE_PATH;
12
+ }
13
+ return path.join(os.homedir(), M365.CACHE.DEFAULT_DIR, M365.CACHE.DEFAULT_FILE);
14
+ }
15
+ function createCachePlugin(cachePath) {
16
+ return {
17
+ beforeCacheAccess: async (cacheContext) => {
18
+ if (fs.existsSync(cachePath)) {
19
+ cacheContext.tokenCache.deserialize(fs.readFileSync(cachePath, 'utf8'));
20
+ }
21
+ },
22
+ afterCacheAccess: async (cacheContext) => {
23
+ if (cacheContext.cacheHasChanged) {
24
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
25
+ fs.writeFileSync(cachePath, cacheContext.tokenCache.serialize());
26
+ }
27
+ },
28
+ };
29
+ }
30
+ function getClient() {
31
+ if (!pca) {
32
+ pca = new PublicClientApplication({
33
+ auth: {
34
+ clientId: env.CLIENT_ID,
35
+ authority: M365.GRAPH.AUTHORITY_TEMPLATE.replace('{tenant}', env.TENANT_ID),
36
+ },
37
+ cache: { cachePlugin: createCachePlugin(getTokenCachePath()) },
38
+ });
39
+ }
40
+ return pca;
41
+ }
42
+ export async function getAccessToken() {
43
+ if (authInProgress) {
44
+ return authInProgress;
45
+ }
46
+ authInProgress = acquireAccessToken();
47
+ try {
48
+ return await authInProgress;
49
+ }
50
+ finally {
51
+ authInProgress = undefined;
52
+ }
53
+ }
54
+ async function acquireAccessToken() {
55
+ const client = getClient();
56
+ const accounts = await client.getTokenCache().getAllAccounts();
57
+ if (accounts.length > 0) {
58
+ try {
59
+ const silent = await client.acquireTokenSilent({
60
+ scopes: [...M365.GRAPH.SCOPES],
61
+ account: accounts[0],
62
+ });
63
+ if (silent?.accessToken) {
64
+ return silent.accessToken;
65
+ }
66
+ }
67
+ catch {
68
+ // Device code login required.
69
+ }
70
+ }
71
+ const device = await client.acquireTokenByDeviceCode({
72
+ deviceCodeCallback: (response) => {
73
+ console.error(`\n${response.message}\n`);
74
+ },
75
+ scopes: [...M365.GRAPH.SCOPES],
76
+ });
77
+ if (!device?.accessToken) {
78
+ throw new Error(M365.MESSAGES.NOT_AUTHENTICATED);
79
+ }
80
+ return device.accessToken;
81
+ }
@@ -0,0 +1,48 @@
1
+ import { M365 } from '../config/m365.config.js';
2
+ import { env } from '../env.js';
3
+ import { getAccessToken } from './authService.js';
4
+ function buildUrl(resourcePath, query) {
5
+ const base = resourcePath.startsWith('http')
6
+ ? resourcePath
7
+ : `${env.GRAPH_BASE_URL.replace(/\/$/, '')}/${resourcePath.replace(/^\//, '')}`;
8
+ const url = new URL(base);
9
+ if (query) {
10
+ for (const [key, value] of Object.entries(query)) {
11
+ if (value !== undefined) {
12
+ url.searchParams.set(key, String(value));
13
+ }
14
+ }
15
+ }
16
+ return url.toString();
17
+ }
18
+ export function isSuccessStatus(status) {
19
+ return status >= M365.HTTP.SUCCESS_MIN && status <= M365.HTTP.SUCCESS_MAX;
20
+ }
21
+ export async function graphFetch(resourcePath, options = {}) {
22
+ const token = await getAccessToken();
23
+ const url = buildUrl(resourcePath, options.query);
24
+ let response;
25
+ try {
26
+ response = await fetch(url, {
27
+ method: options.method ?? 'GET',
28
+ headers: {
29
+ Authorization: `Bearer ${token}`,
30
+ Accept: 'application/json',
31
+ ...(options.body ? { 'Content-Type': 'application/json' } : {}),
32
+ },
33
+ body: options.body ? JSON.stringify(options.body) : undefined,
34
+ });
35
+ }
36
+ catch (error) {
37
+ const message = error instanceof Error ? error.message : String(error);
38
+ throw new Error(`${M365.MESSAGES.NETWORK_ERROR} ${message}`);
39
+ }
40
+ if (!isSuccessStatus(response.status)) {
41
+ const body = (await response.text()).slice(0, M365.HTTP.ERROR_BODY_SLICE);
42
+ throw new Error(`${M365.MESSAGES.GRAPH_FAILED} HTTP ${response.status} ${body}`);
43
+ }
44
+ if (response.status === 204) {
45
+ return undefined;
46
+ }
47
+ return (await response.json());
48
+ }
@@ -0,0 +1,71 @@
1
+ import { graphFetch } from './graphClient.js';
2
+ export async function getMe() {
3
+ return graphFetch('/me');
4
+ }
5
+ export async function listInboxMessages(top, unreadOnly) {
6
+ const filter = unreadOnly ? 'isRead eq false' : undefined;
7
+ return graphFetch('/me/messages', {
8
+ query: {
9
+ $top: top,
10
+ $orderby: 'receivedDateTime desc',
11
+ $select: 'id,subject,bodyPreview,isRead,receivedDateTime,from',
12
+ ...(filter ? { $filter: filter } : {}),
13
+ },
14
+ });
15
+ }
16
+ export async function getMessage(messageId) {
17
+ return graphFetch(`/me/messages/${messageId}`, {
18
+ query: { $select: 'id,subject,bodyPreview,isRead,receivedDateTime,from,body' },
19
+ });
20
+ }
21
+ export async function sendMail(to, subject, body) {
22
+ await graphFetch('/me/sendMail', {
23
+ method: 'POST',
24
+ body: {
25
+ message: {
26
+ subject,
27
+ body: { contentType: 'Text', content: body },
28
+ toRecipients: [{ emailAddress: { address: to } }],
29
+ },
30
+ saveToSentItems: true,
31
+ },
32
+ });
33
+ }
34
+ export async function listCalendarEvents(start, end, top) {
35
+ return graphFetch('/me/calendarView', {
36
+ query: {
37
+ startDateTime: start,
38
+ endDateTime: end,
39
+ $top: top,
40
+ $orderby: 'start/dateTime',
41
+ $select: 'id,subject,start,end,location,organizer,isOnlineMeeting,onlineMeetingUrl',
42
+ },
43
+ });
44
+ }
45
+ export async function listJoinedTeams() {
46
+ return graphFetch('/me/joinedTeams');
47
+ }
48
+ export async function listChats(top) {
49
+ return graphFetch('/me/chats', {
50
+ query: { $top: top },
51
+ });
52
+ }
53
+ export async function sendChatMessage(chatId, content) {
54
+ await graphFetch(`/chats/${chatId}/messages`, {
55
+ method: 'POST',
56
+ body: {
57
+ body: { contentType: 'text', content },
58
+ },
59
+ });
60
+ }
61
+ export async function listTeamChannels(teamId) {
62
+ return graphFetch(`/teams/${teamId}/channels`);
63
+ }
64
+ export async function sendChannelMessage(teamId, channelId, content) {
65
+ await graphFetch(`/teams/${teamId}/channels/${channelId}/messages`, {
66
+ method: 'POST',
67
+ body: {
68
+ body: { contentType: 'text', content },
69
+ },
70
+ });
71
+ }
@@ -0,0 +1,22 @@
1
+ import { registerListCalendarTool } from './listCalendarTool.js';
2
+ import { registerListChannelsTool } from './listChannelsTool.js';
3
+ import { registerListInboxTool } from './listInboxTool.js';
4
+ import { registerListTeamsChatsTool } from './listTeamsChatsTool.js';
5
+ import { registerListTeamsTool } from './listTeamsTool.js';
6
+ import { registerReadEmailTool } from './readEmailTool.js';
7
+ import { registerSendChannelMessageTool } from './sendChannelMessageTool.js';
8
+ import { registerSendEmailTool } from './sendEmailTool.js';
9
+ import { registerSendTeamsChatTool } from './sendTeamsChatTool.js';
10
+ import { registerWhoamiTool } from './whoamiTool.js';
11
+ export function registerTools(server) {
12
+ registerWhoamiTool(server);
13
+ registerListInboxTool(server);
14
+ registerReadEmailTool(server);
15
+ registerSendEmailTool(server);
16
+ registerListCalendarTool(server);
17
+ registerListTeamsTool(server);
18
+ registerListTeamsChatsTool(server);
19
+ registerSendTeamsChatTool(server);
20
+ registerListChannelsTool(server);
21
+ registerSendChannelMessageTool(server);
22
+ }
@@ -0,0 +1,28 @@
1
+ import { z } from 'zod';
2
+ import { M365 } from '../config/m365.config.js';
3
+ import { listCalendarEvents } from '../services/graphService.js';
4
+ import { clampTop, formatError, todayRangeIso } from '../utils/format.js';
5
+ export function registerListCalendarTool(server) {
6
+ const cfg = M365.TOOLS.LIST_CALENDAR;
7
+ server.tool(cfg.NAME, cfg.DESCRIPTION, {
8
+ start: z.string().optional().describe(cfg.START_DESCRIPTION),
9
+ end: z.string().optional().describe(cfg.END_DESCRIPTION),
10
+ top: z.number().int().positive().max(M365.LIMITS.MAX_TOP).optional().describe(cfg.TOP_DESCRIPTION),
11
+ }, async ({ start, end, top }) => {
12
+ try {
13
+ const range = todayRangeIso();
14
+ const result = await listCalendarEvents(start ?? range.start, end ?? range.end, clampTop(top));
15
+ if (!result.value.length) {
16
+ return { content: [{ type: 'text', text: cfg.EMPTY }] };
17
+ }
18
+ const lines = result.value.map((event, index) => {
19
+ const location = event.location?.displayName ?? '(no location)';
20
+ return `${index + 1}. ${event.subject ?? '(no subject)'}\n start: ${event.start?.dateTime ?? '?'} ${event.start?.timeZone ?? ''}\n end: ${event.end?.dateTime ?? '?'}\n location: ${location}`;
21
+ });
22
+ return { content: [{ type: 'text', text: `${result.value.length} event(s):\n\n${lines.join('\n\n')}` }] };
23
+ }
24
+ catch (error) {
25
+ return { isError: true, content: [{ type: 'text', text: formatError(error, cfg.FAILURE) }] };
26
+ }
27
+ });
28
+ }
@@ -0,0 +1,20 @@
1
+ import { z } from 'zod';
2
+ import { M365 } from '../config/m365.config.js';
3
+ import { listTeamChannels } from '../services/graphService.js';
4
+ import { formatError } from '../utils/format.js';
5
+ export function registerListChannelsTool(server) {
6
+ const cfg = M365.TOOLS.LIST_CHANNELS;
7
+ server.tool(cfg.NAME, cfg.DESCRIPTION, { teamId: z.string().min(1).describe(cfg.TEAM_ID_DESCRIPTION) }, async ({ teamId }) => {
8
+ try {
9
+ const result = await listTeamChannels(teamId);
10
+ if (!result.value.length) {
11
+ return { content: [{ type: 'text', text: cfg.EMPTY }] };
12
+ }
13
+ const lines = result.value.map((channel, index) => `${index + 1}. ${channel.displayName ?? '(unnamed)'}\n id: ${channel.id}\n membership: ${channel.membershipType ?? '?'}`);
14
+ return { content: [{ type: 'text', text: `${result.value.length} channel(s):\n\n${lines.join('\n\n')}` }] };
15
+ }
16
+ catch (error) {
17
+ return { isError: true, content: [{ type: 'text', text: formatError(error, cfg.FAILURE) }] };
18
+ }
19
+ });
20
+ }
@@ -0,0 +1,28 @@
1
+ import { z } from 'zod';
2
+ import { M365 } from '../config/m365.config.js';
3
+ import { listInboxMessages } from '../services/graphService.js';
4
+ import { clampTop, formatError } from '../utils/format.js';
5
+ export function registerListInboxTool(server) {
6
+ const cfg = M365.TOOLS.LIST_INBOX;
7
+ server.tool(cfg.NAME, cfg.DESCRIPTION, {
8
+ top: z.number().int().positive().max(M365.LIMITS.MAX_TOP).optional().describe(cfg.TOP_DESCRIPTION),
9
+ unreadOnly: z.boolean().optional().describe(cfg.UNREAD_ONLY_DESCRIPTION),
10
+ }, async ({ top, unreadOnly }) => {
11
+ try {
12
+ const result = await listInboxMessages(clampTop(top), unreadOnly ?? false);
13
+ if (!result.value.length) {
14
+ return { content: [{ type: 'text', text: cfg.EMPTY }] };
15
+ }
16
+ const lines = result.value.map((msg, index) => {
17
+ const from = msg.from?.emailAddress?.address ?? '(unknown sender)';
18
+ const subject = (msg.subject ?? '(no subject)').slice(0, M365.LIMITS.SUBJECT_SLICE);
19
+ const read = msg.isRead ? 'read' : 'unread';
20
+ return `${index + 1}. [${read}] id=${msg.id}\n from: ${from}\n subject: ${subject}\n received: ${msg.receivedDateTime ?? '?'}\n preview: ${(msg.bodyPreview ?? '').slice(0, M365.LIMITS.PREVIEW_SLICE)}`;
21
+ });
22
+ return { content: [{ type: 'text', text: `${result.value.length} message(s):\n\n${lines.join('\n\n')}` }] };
23
+ }
24
+ catch (error) {
25
+ return { isError: true, content: [{ type: 'text', text: formatError(error, cfg.FAILURE) }] };
26
+ }
27
+ });
28
+ }
@@ -0,0 +1,20 @@
1
+ import { z } from 'zod';
2
+ import { M365 } from '../config/m365.config.js';
3
+ import { listChats } from '../services/graphService.js';
4
+ import { clampTop, formatError } from '../utils/format.js';
5
+ export function registerListTeamsChatsTool(server) {
6
+ const cfg = M365.TOOLS.LIST_TEAMS_CHATS;
7
+ server.tool(cfg.NAME, cfg.DESCRIPTION, { top: z.number().int().positive().max(M365.LIMITS.MAX_TOP).optional().describe(cfg.TOP_DESCRIPTION) }, async ({ top }) => {
8
+ try {
9
+ const result = await listChats(clampTop(top));
10
+ if (!result.value.length) {
11
+ return { content: [{ type: 'text', text: cfg.EMPTY }] };
12
+ }
13
+ const lines = result.value.map((chat, index) => `${index + 1}. ${chat.topic ?? '(no topic)'}\n id: ${chat.id}\n type: ${chat.chatType ?? '?'}\n updated: ${chat.lastUpdatedDateTime ?? '?'}`);
14
+ return { content: [{ type: 'text', text: `${result.value.length} chat(s):\n\n${lines.join('\n\n')}` }] };
15
+ }
16
+ catch (error) {
17
+ return { isError: true, content: [{ type: 'text', text: formatError(error, cfg.FAILURE) }] };
18
+ }
19
+ });
20
+ }
@@ -0,0 +1,19 @@
1
+ import { M365 } from '../config/m365.config.js';
2
+ import { listJoinedTeams } from '../services/graphService.js';
3
+ import { formatError } from '../utils/format.js';
4
+ export function registerListTeamsTool(server) {
5
+ const cfg = M365.TOOLS.LIST_TEAMS;
6
+ server.tool(cfg.NAME, cfg.DESCRIPTION, {}, async () => {
7
+ try {
8
+ const result = await listJoinedTeams();
9
+ if (!result.value.length) {
10
+ return { content: [{ type: 'text', text: cfg.EMPTY }] };
11
+ }
12
+ const lines = result.value.map((team, index) => `${index + 1}. ${team.displayName ?? '(unnamed)'}\n id: ${team.id}\n description: ${team.description ?? '(none)'}`);
13
+ return { content: [{ type: 'text', text: `${result.value.length} team(s):\n\n${lines.join('\n\n')}` }] };
14
+ }
15
+ catch (error) {
16
+ return { isError: true, content: [{ type: 'text', text: formatError(error, cfg.FAILURE) }] };
17
+ }
18
+ });
19
+ }
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod';
2
+ import { M365 } from '../config/m365.config.js';
3
+ import { getMessage } from '../services/graphService.js';
4
+ import { formatError } from '../utils/format.js';
5
+ export function registerReadEmailTool(server) {
6
+ const cfg = M365.TOOLS.READ_EMAIL;
7
+ server.tool(cfg.NAME, cfg.DESCRIPTION, { messageId: z.string().min(1).describe(cfg.MESSAGE_ID_DESCRIPTION) }, async ({ messageId }) => {
8
+ try {
9
+ const msg = await getMessage(messageId);
10
+ const from = msg.from?.emailAddress?.address ?? '(unknown sender)';
11
+ const body = msg.body?.content ?? msg.bodyPreview ?? '(no body)';
12
+ return {
13
+ content: [{
14
+ type: 'text',
15
+ text: [
16
+ `id: ${msg.id}`,
17
+ `from: ${from}`,
18
+ `subject: ${msg.subject ?? '(no subject)'}`,
19
+ `received: ${msg.receivedDateTime ?? '?'}`,
20
+ `isRead: ${msg.isRead ?? false}`,
21
+ '',
22
+ '--- body ---',
23
+ body,
24
+ ].join('\n'),
25
+ }],
26
+ };
27
+ }
28
+ catch (error) {
29
+ return { isError: true, content: [{ type: 'text', text: formatError(error, cfg.FAILURE) }] };
30
+ }
31
+ });
32
+ }
@@ -0,0 +1,20 @@
1
+ import { z } from 'zod';
2
+ import { M365 } from '../config/m365.config.js';
3
+ import { sendChannelMessage } from '../services/graphService.js';
4
+ import { formatError } from '../utils/format.js';
5
+ export function registerSendChannelMessageTool(server) {
6
+ const cfg = M365.TOOLS.SEND_CHANNEL_MESSAGE;
7
+ server.tool(cfg.NAME, cfg.DESCRIPTION, {
8
+ teamId: z.string().min(1).describe(cfg.TEAM_ID_DESCRIPTION),
9
+ channelId: z.string().min(1).describe(cfg.CHANNEL_ID_DESCRIPTION),
10
+ message: z.string().min(1).describe(cfg.MESSAGE_DESCRIPTION),
11
+ }, async ({ teamId, channelId, message }) => {
12
+ try {
13
+ await sendChannelMessage(teamId, channelId, message);
14
+ return { content: [{ type: 'text', text: `${cfg.SUCCESS}\nTeam: ${teamId}\nChannel: ${channelId}` }] };
15
+ }
16
+ catch (error) {
17
+ return { isError: true, content: [{ type: 'text', text: formatError(error, cfg.FAILURE) }] };
18
+ }
19
+ });
20
+ }
@@ -0,0 +1,20 @@
1
+ import { z } from 'zod';
2
+ import { M365 } from '../config/m365.config.js';
3
+ import { sendMail } from '../services/graphService.js';
4
+ import { formatError } from '../utils/format.js';
5
+ export function registerSendEmailTool(server) {
6
+ const cfg = M365.TOOLS.SEND_EMAIL;
7
+ server.tool(cfg.NAME, cfg.DESCRIPTION, {
8
+ to: z.string().email().describe(cfg.TO_DESCRIPTION),
9
+ subject: z.string().min(1).describe(cfg.SUBJECT_DESCRIPTION),
10
+ body: z.string().min(1).describe(cfg.BODY_DESCRIPTION),
11
+ }, async ({ to, subject, body }) => {
12
+ try {
13
+ await sendMail(to, subject, body);
14
+ return { content: [{ type: 'text', text: `${cfg.SUCCESS}\nTo: ${to}\nSubject: ${subject}` }] };
15
+ }
16
+ catch (error) {
17
+ return { isError: true, content: [{ type: 'text', text: formatError(error, cfg.FAILURE) }] };
18
+ }
19
+ });
20
+ }
@@ -0,0 +1,19 @@
1
+ import { z } from 'zod';
2
+ import { M365 } from '../config/m365.config.js';
3
+ import { sendChatMessage } from '../services/graphService.js';
4
+ import { formatError } from '../utils/format.js';
5
+ export function registerSendTeamsChatTool(server) {
6
+ const cfg = M365.TOOLS.SEND_TEAMS_CHAT;
7
+ server.tool(cfg.NAME, cfg.DESCRIPTION, {
8
+ chatId: z.string().min(1).describe(cfg.CHAT_ID_DESCRIPTION),
9
+ message: z.string().min(1).describe(cfg.MESSAGE_DESCRIPTION),
10
+ }, async ({ chatId, message }) => {
11
+ try {
12
+ await sendChatMessage(chatId, message);
13
+ return { content: [{ type: 'text', text: `${cfg.SUCCESS}\nChat: ${chatId}` }] };
14
+ }
15
+ catch (error) {
16
+ return { isError: true, content: [{ type: 'text', text: formatError(error, cfg.FAILURE) }] };
17
+ }
18
+ });
19
+ }
@@ -0,0 +1,26 @@
1
+ import { M365 } from '../config/m365.config.js';
2
+ import { getMe } from '../services/graphService.js';
3
+ import { formatError } from '../utils/format.js';
4
+ export function registerWhoamiTool(server) {
5
+ const cfg = M365.TOOLS.WHOAMI;
6
+ server.tool(cfg.NAME, cfg.DESCRIPTION, {}, async () => {
7
+ try {
8
+ const user = await getMe();
9
+ return {
10
+ content: [{
11
+ type: 'text',
12
+ text: [
13
+ 'Signed-in Microsoft 365 user:',
14
+ `- id: ${user.id}`,
15
+ `- displayName: ${user.displayName ?? '(unknown)'}`,
16
+ `- email: ${user.mail ?? user.userPrincipalName ?? '(unknown)'}`,
17
+ `- jobTitle: ${user.jobTitle ?? '(not set)'}`,
18
+ ].join('\n'),
19
+ }],
20
+ };
21
+ }
22
+ catch (error) {
23
+ return { isError: true, content: [{ type: 'text', text: formatError(error, cfg.FAILURE) }] };
24
+ }
25
+ });
26
+ }
@@ -0,0 +1,17 @@
1
+ import { M365 } from '../config/m365.config.js';
2
+ export function clampTop(top) {
3
+ const value = top ?? M365.LIMITS.DEFAULT_TOP;
4
+ return Math.min(Math.max(1, value), M365.LIMITS.MAX_TOP);
5
+ }
6
+ export function formatError(error, fallback) {
7
+ const message = error instanceof Error ? error.message : fallback;
8
+ return `${M365.MESSAGES.GENERIC_ERROR_PREFIX} ${message}`;
9
+ }
10
+ export function todayRangeIso() {
11
+ const now = new Date();
12
+ const start = new Date(now);
13
+ start.setHours(0, 0, 0, 0);
14
+ const end = new Date(now);
15
+ end.setHours(23, 59, 59, 999);
16
+ return { start: start.toISOString(), end: end.toISOString() };
17
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@mhdd_24/m365-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for Microsoft Outlook and Teams via Microsoft Graph API",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "bin": {
8
+ "m365-mcp": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "scripts": {
18
+ "dev": "tsx src/index.ts",
19
+ "build": "tsc",
20
+ "prepack": "npm run build",
21
+ "start": "node dist/index.js",
22
+ "login": "tsx src/scripts/login.ts"
23
+ },
24
+ "keywords": [
25
+ "mcp",
26
+ "model-context-protocol",
27
+ "microsoft-365",
28
+ "outlook",
29
+ "teams",
30
+ "graph-api",
31
+ "cursor"
32
+ ],
33
+ "author": "Mhdd-24",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/Mhdd-24/M365-MCP.git"
37
+ },
38
+ "bugs": {
39
+ "url": "https://github.com/Mhdd-24/M365-MCP/issues"
40
+ },
41
+ "homepage": "https://github.com/Mhdd-24/M365-MCP#readme",
42
+ "license": "ISC",
43
+ "engines": {
44
+ "node": ">=18"
45
+ },
46
+ "dependencies": {
47
+ "@azure/msal-node": "^3.8.4",
48
+ "@modelcontextprotocol/sdk": "^1.29.0",
49
+ "dotenv": "^17.4.2",
50
+ "zod": "^4.4.3"
51
+ },
52
+ "devDependencies": {
53
+ "@types/node": "^26.1.0",
54
+ "tsx": "^4.22.4",
55
+ "typescript": "^6.0.3"
56
+ }
57
+ }