@softeria/ms-365-mcp-server 0.1.9

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.
@@ -0,0 +1,33 @@
1
+ # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created
2
+ # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages
3
+
4
+ name: Node.js Package
5
+
6
+ on:
7
+ release:
8
+ types: [ created ]
9
+
10
+ jobs:
11
+ build:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-node@v4
16
+ with:
17
+ node-version: 20
18
+ - run: npm ci
19
+ - run: npm test
20
+
21
+ publish-npm:
22
+ needs: build
23
+ runs-on: ubuntu-latest
24
+ steps:
25
+ - uses: actions/checkout@v4
26
+ - uses: actions/setup-node@v4
27
+ with:
28
+ node-version: 20
29
+ registry-url: https://registry.npmjs.org/
30
+ - run: npm ci
31
+ - run: npm publish
32
+ env:
33
+ NODE_AUTH_TOKEN: ${{secrets.npm_token}}
package/.prettierrc ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "semi": true,
3
+ "singleQuote": true,
4
+ "trailingComma": "es5",
5
+ "printWidth": 100,
6
+ "tabWidth": 2
7
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Softeria
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # ms-365-mcp-server
2
+
3
+ Microsoft 365 MCP Server
4
+
5
+ A Model Context Protocol (MCP) server for interacting with Microsoft 365 services through the Graph API.
6
+
7
+ [![Test Status](https://img.shields.io/badge/tests-passing-brightgreen)]()
8
+
9
+ ## Features
10
+
11
+ - Authentication using Microsoft Authentication Library (MSAL)
12
+ - Excel file operations:
13
+ - Update cell values
14
+ - Create and manage charts
15
+ - Format cells
16
+ - Sort data
17
+ - Create tables
18
+ - Read cell values
19
+ - List worksheets
20
+ - Built on the Model Context Protocol
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ npm install -g ms-365-mcp-server
26
+ ```
27
+
28
+ Or use directly with npx:
29
+
30
+ ```bash
31
+ npx ms-365-mcp-server
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ### Command Line Options
37
+
38
+ ```bash
39
+ npx ms-365-mcp-server [options]
40
+ ```
41
+
42
+ Options:
43
+
44
+ - `--login`: Force login using device code flow
45
+ - `--logout`: Log out and clear saved credentials
46
+ - `--file <path>`: Excel file path to use (default: "/Livet.xlsx")
47
+ - `--silent`: Run without informational messages to stderr
48
+
49
+ ### Authentication
50
+
51
+ The first time you run the server, it will automatically initiate the device code flow authentication. You'll see instructions in the terminal about how to complete the authentication in your browser.
52
+
53
+ Authentication tokens are cached securely in your system's credential store with fallback to file storage if needed.
54
+
55
+ ### MCP Tools
56
+
57
+ This server provides several MCP tools for interacting with Excel files:
58
+
59
+ - `login`: Force a new login with Microsoft
60
+ - `logout`: Log out of Microsoft and clear credentials
61
+ - `update-excel`: Update cell values in an Excel worksheet
62
+ - `create-chart`: Create a chart in an Excel worksheet
63
+ - `format-range`: Apply formatting to a range of cells
64
+ - `sort-range`: Sort a range of cells
65
+ - `create-table`: Create a table from a range of cells
66
+ - `get-range`: Get values from a range of cells
67
+ - `list-worksheets`: List all worksheets in the workbook
68
+ - `close-session`: Close the current Excel session
69
+ - `delete-chart`: Delete a chart from a worksheet
70
+ - `get-charts`: Get all charts in a worksheet
package/auth.mjs ADDED
@@ -0,0 +1,153 @@
1
+ import { PublicClientApplication } from '@azure/msal-node';
2
+ import keytar from 'keytar';
3
+ import { fileURLToPath } from 'url';
4
+ import path from 'path';
5
+ import fs from 'fs';
6
+ import logger from './logger.mjs';
7
+
8
+ const SERVICE_NAME = 'ms-365-mcp-server';
9
+ const TOKEN_CACHE_ACCOUNT = 'msal-token-cache';
10
+ const FALLBACK_DIR = path.dirname(fileURLToPath(import.meta.url));
11
+ const FALLBACK_PATH = path.join(FALLBACK_DIR, '.token-cache.json');
12
+
13
+ const DEFAULT_CONFIG = {
14
+ auth: {
15
+ clientId: '084a3e9f-a9f4-43f7-89f9-d229cf97853e',
16
+ authority: 'https://login.microsoftonline.com/common',
17
+ },
18
+ };
19
+
20
+ const DEFAULT_SCOPES = [
21
+ 'Files.ReadWrite',
22
+ 'Files.ReadWrite.All',
23
+ 'Sites.ReadWrite.All',
24
+ 'User.Read',
25
+ 'User.ReadBasic.All',
26
+ ];
27
+
28
+ class AuthManager {
29
+ constructor(config = DEFAULT_CONFIG, scopes = DEFAULT_SCOPES) {
30
+ this.config = config;
31
+ this.scopes = scopes;
32
+ this.msalApp = new PublicClientApplication(this.config);
33
+ this.accessToken = null;
34
+ this.tokenExpiry = null;
35
+ }
36
+
37
+ async loadTokenCache() {
38
+ try {
39
+ let cacheData;
40
+
41
+ try {
42
+ const cachedData = await keytar.getPassword(SERVICE_NAME, TOKEN_CACHE_ACCOUNT);
43
+ if (cachedData) {
44
+ cacheData = cachedData;
45
+ }
46
+ } catch (keytarError) {
47
+ logger.warn(`Keychain access failed, falling back to file storage: ${keytarError.message}`);
48
+ }
49
+
50
+ if (!cacheData && fs.existsSync(FALLBACK_PATH)) {
51
+ cacheData = fs.readFileSync(FALLBACK_PATH, 'utf8');
52
+ }
53
+
54
+ if (cacheData) {
55
+ this.msalApp.getTokenCache().deserialize(cacheData);
56
+ }
57
+ } catch (error) {
58
+ logger.error(`Error loading token cache: ${error.message}`);
59
+ }
60
+ }
61
+
62
+ async saveTokenCache() {
63
+ try {
64
+ const cacheData = this.msalApp.getTokenCache().serialize();
65
+
66
+ try {
67
+ await keytar.setPassword(SERVICE_NAME, TOKEN_CACHE_ACCOUNT, cacheData);
68
+ } catch (keytarError) {
69
+ logger.warn(`Keychain save failed, falling back to file storage: ${keytarError.message}`);
70
+
71
+ fs.writeFileSync(FALLBACK_PATH, cacheData);
72
+ }
73
+ } catch (error) {
74
+ logger.error(`Error saving token cache: ${error.message}`);
75
+ }
76
+ }
77
+
78
+ async getToken(forceRefresh = false) {
79
+ if (this.accessToken && this.tokenExpiry && this.tokenExpiry > Date.now() && !forceRefresh) {
80
+ return this.accessToken;
81
+ }
82
+
83
+ const accounts = await this.msalApp.getTokenCache().getAllAccounts();
84
+
85
+ if (accounts.length > 0) {
86
+ const silentRequest = {
87
+ account: accounts[0],
88
+ scopes: this.scopes,
89
+ };
90
+
91
+ try {
92
+ const response = await this.msalApp.acquireTokenSilent(silentRequest);
93
+ this.accessToken = response.accessToken;
94
+ this.tokenExpiry = new Date(response.expiresOn).getTime();
95
+ return this.accessToken;
96
+ } catch (error) {
97
+ logger.info('Silent token acquisition failed, using device code flow');
98
+ }
99
+ }
100
+
101
+ throw new Error('No valid token found');
102
+ }
103
+
104
+ async acquireTokenByDeviceCode() {
105
+ const deviceCodeRequest = {
106
+ scopes: this.scopes,
107
+ deviceCodeCallback: (response) => {
108
+ // We need to show this message to the user in console
109
+ console.log('\n' + response.message + '\n');
110
+ logger.info('Device code login initiated');
111
+ },
112
+ };
113
+
114
+ try {
115
+ const response = await this.msalApp.acquireTokenByDeviceCode(deviceCodeRequest);
116
+ this.accessToken = response.accessToken;
117
+ this.tokenExpiry = new Date(response.expiresOn).getTime();
118
+ await this.saveTokenCache();
119
+ return this.accessToken;
120
+ } catch (error) {
121
+ logger.error(`Error in device code flow: ${error.message}`);
122
+ throw error;
123
+ }
124
+ }
125
+
126
+ async logout() {
127
+ try {
128
+ const accounts = await this.msalApp.getTokenCache().getAllAccounts();
129
+ for (const account of accounts) {
130
+ await this.msalApp.getTokenCache().removeAccount(account);
131
+ }
132
+ this.accessToken = null;
133
+ this.tokenExpiry = null;
134
+
135
+ try {
136
+ await keytar.deletePassword(SERVICE_NAME, TOKEN_CACHE_ACCOUNT);
137
+ } catch (keytarError) {
138
+ logger.warn(`Keychain deletion failed: ${keytarError.message}`);
139
+ }
140
+
141
+ if (fs.existsSync(FALLBACK_PATH)) {
142
+ fs.unlinkSync(FALLBACK_PATH);
143
+ }
144
+
145
+ return true;
146
+ } catch (error) {
147
+ logger.error(`Error during logout: ${error.message}`);
148
+ throw error;
149
+ }
150
+ }
151
+ }
152
+
153
+ export default AuthManager;
package/cli.mjs ADDED
@@ -0,0 +1,21 @@
1
+ import { Command } from 'commander';
2
+ import { readFileSync } from 'fs';
3
+
4
+ const packageJson = JSON.parse(readFileSync(new URL('./package.json', import.meta.url)));
5
+ const version = packageJson.version;
6
+
7
+ const program = new Command();
8
+
9
+ program
10
+ .name('ms-365-mcp-server')
11
+ .description('Microsoft 365 MCP Server')
12
+ .version(version)
13
+ .option('-v', 'Enable verbose logging')
14
+ .option('--login', 'Login using device code flow')
15
+ .option('--logout', 'Log out and clear saved credentials')
16
+ .option('--test-login', 'Test login without starting the server');
17
+
18
+ export function parseArgs() {
19
+ program.parse();
20
+ return program.opts();
21
+ }
package/index.mjs ADDED
@@ -0,0 +1,518 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import { z } from 'zod';
6
+ import { parseArgs } from './cli.mjs';
7
+ import { readFileSync } from 'fs';
8
+ import logger, { enableConsoleLogging } from './logger.mjs';
9
+ import AuthManager from './auth.mjs';
10
+
11
+ const packageJson = JSON.parse(readFileSync(new URL('./package.json', import.meta.url)));
12
+ export const version = packageJson.version;
13
+
14
+ const args = parseArgs();
15
+ const filePath = args.file || '/Livet.xlsx';
16
+
17
+ const authManager = new AuthManager();
18
+ await authManager.loadTokenCache();
19
+
20
+ let sessionId = null;
21
+
22
+ async function createSession() {
23
+ try {
24
+ logger.info('Creating new Excel session...');
25
+ const accessToken = await authManager.getToken();
26
+
27
+ const response = await fetch(
28
+ `https://graph.microsoft.com/v1.0/me/drive/root:${filePath}:/workbook/createSession`,
29
+ {
30
+ method: 'POST',
31
+ headers: {
32
+ Authorization: `Bearer ${accessToken}`,
33
+ 'Content-Type': 'application/json',
34
+ },
35
+ body: JSON.stringify({ persistChanges: true }),
36
+ }
37
+ );
38
+
39
+ if (!response.ok) {
40
+ const errorText = await response.text();
41
+ logger.error(`Failed to create session: ${response.status} - ${errorText}`);
42
+ return null;
43
+ }
44
+
45
+ const result = await response.json();
46
+ logger.info('Session created successfully');
47
+ sessionId = result.id;
48
+ return sessionId;
49
+ } catch (error) {
50
+ logger.error(`Error creating Excel session: ${error}`);
51
+ return null;
52
+ }
53
+ }
54
+
55
+ async function graphRequest(endpoint, options = {}) {
56
+ try {
57
+ let accessToken = await authManager.getToken();
58
+
59
+ const headers = {
60
+ Authorization: `Bearer ${accessToken}`,
61
+ 'Content-Type': 'application/json',
62
+ ...(sessionId && { 'workbook-session-id': sessionId }),
63
+ ...options.headers,
64
+ };
65
+
66
+ const response = await fetch(
67
+ `https://graph.microsoft.com/v1.0/me/drive/root:${filePath}:${endpoint}`,
68
+ {
69
+ headers,
70
+ ...options,
71
+ }
72
+ );
73
+
74
+ if (response.status === 401) {
75
+ logger.info('Access token expired, refreshing...');
76
+ const newToken = await authManager.getToken(true);
77
+ await createSession();
78
+
79
+ headers.Authorization = `Bearer ${newToken}`;
80
+ if (sessionId) {
81
+ headers['workbook-session-id'] = sessionId;
82
+ }
83
+
84
+ const retryResponse = await fetch(
85
+ `https://graph.microsoft.com/v1.0/me/drive/root:${filePath}:${endpoint}`,
86
+ {
87
+ headers,
88
+ ...options,
89
+ }
90
+ );
91
+
92
+ if (!retryResponse.ok) {
93
+ throw new Error(`Graph API error: ${retryResponse.status} ${await retryResponse.text()}`);
94
+ }
95
+
96
+ return formatResponse(retryResponse);
97
+ }
98
+
99
+ if (!response.ok) {
100
+ throw new Error(`Graph API error: ${response.status} ${await response.text()}`);
101
+ }
102
+
103
+ return formatResponse(response);
104
+ } catch (error) {
105
+ logger.error(`Error in Graph API request: ${error}`);
106
+ return {
107
+ content: [{ type: 'text', text: JSON.stringify({ error: error.message }) }],
108
+ };
109
+ }
110
+ }
111
+
112
+ async function formatResponse(response) {
113
+ try {
114
+ if (response.status === 204) {
115
+ return {
116
+ content: [
117
+ {
118
+ type: 'text',
119
+ text: JSON.stringify({
120
+ message: 'Operation completed successfully',
121
+ }),
122
+ },
123
+ ],
124
+ };
125
+ }
126
+
127
+ const result = await response.json();
128
+
129
+ const removeODataProps = (obj) => {
130
+ if (!obj || typeof obj !== 'object') return;
131
+
132
+ if (Array.isArray(obj)) {
133
+ obj.forEach((item) => removeODataProps(item));
134
+ } else {
135
+ Object.keys(obj).forEach((key) => {
136
+ if (key.startsWith('@odata')) {
137
+ delete obj[key];
138
+ } else if (typeof obj[key] === 'object') {
139
+ removeODataProps(obj[key]);
140
+ }
141
+ });
142
+ }
143
+ };
144
+
145
+ removeODataProps(result);
146
+
147
+ return {
148
+ content: [{ type: 'text', text: JSON.stringify(result) }],
149
+ };
150
+ } catch (error) {
151
+ return {
152
+ content: [{ type: 'text', text: JSON.stringify({ message: 'Success' }) }],
153
+ };
154
+ }
155
+ }
156
+
157
+ const server = new McpServer({
158
+ name: 'ExcelUpdater',
159
+ version,
160
+ });
161
+
162
+ server.tool('login', {}, async () => {
163
+ try {
164
+ await authManager.getToken(true);
165
+ return {
166
+ content: [
167
+ {
168
+ type: 'text',
169
+ text: JSON.stringify({ message: 'Authentication successful' }),
170
+ },
171
+ ],
172
+ };
173
+ } catch (error) {
174
+ return {
175
+ content: [
176
+ {
177
+ type: 'text',
178
+ text: JSON.stringify({ error: 'Authentication failed' }),
179
+ },
180
+ ],
181
+ };
182
+ }
183
+ });
184
+
185
+ server.tool('logout', {}, async () => {
186
+ try {
187
+ await authManager.logout();
188
+ return {
189
+ content: [
190
+ {
191
+ type: 'text',
192
+ text: JSON.stringify({ message: 'Logged out successfully' }),
193
+ },
194
+ ],
195
+ };
196
+ } catch (error) {
197
+ return {
198
+ content: [
199
+ {
200
+ type: 'text',
201
+ text: JSON.stringify({ error: 'Logout failed' }),
202
+ },
203
+ ],
204
+ };
205
+ }
206
+ });
207
+
208
+ server.tool(
209
+ 'update-excel',
210
+ {
211
+ worksheet: z.string().default('Sheet1').describe('Worksheet name'),
212
+ address: z.string().describe("Range address (e.g., 'A1:B5')"),
213
+ values: z.array(z.array(z.any())).describe('Values to update'),
214
+ },
215
+ async ({ worksheet, address, values }) => {
216
+ return graphRequest(`/workbook/worksheets('${worksheet}')/range(address='${address}')`, {
217
+ method: 'PATCH',
218
+ body: JSON.stringify({ values }),
219
+ });
220
+ }
221
+ );
222
+
223
+ server.tool(
224
+ 'create-chart',
225
+ {
226
+ worksheet: z.string().default('Sheet1').describe('Worksheet name'),
227
+ type: z.string().describe("Chart type (e.g., 'ColumnClustered', 'Line', 'Pie')"),
228
+ dataRange: z.string().describe("Data range for the chart (e.g., 'A1:B10')"),
229
+ title: z.string().optional().describe('Title for the chart'),
230
+ position: z
231
+ .object({
232
+ x: z.number().describe('X position'),
233
+ y: z.number().describe('Y position'),
234
+ width: z.number().describe('Width'),
235
+ height: z.number().describe('Height'),
236
+ })
237
+ .describe('Chart position and dimensions'),
238
+ },
239
+ async ({ worksheet, type, dataRange, title, position }) => {
240
+ const body = {
241
+ type,
242
+ sourceData: dataRange,
243
+ position,
244
+ };
245
+
246
+ if (title) {
247
+ body.title = { text: title };
248
+ }
249
+
250
+ return graphRequest(`/workbook/worksheets('${worksheet}')/charts/add`, {
251
+ method: 'POST',
252
+ body: JSON.stringify(body),
253
+ });
254
+ }
255
+ );
256
+
257
+ server.tool(
258
+ 'format-range',
259
+ {
260
+ worksheet: z.string().default('Sheet1').describe('Worksheet name'),
261
+ range: z.string().describe("Range address (e.g., 'A1:B5')"),
262
+ format: z
263
+ .object({
264
+ fill: z
265
+ .object({
266
+ color: z.string().optional().describe("Background color (e.g., '#FFFF00')"),
267
+ })
268
+ .optional(),
269
+ font: z
270
+ .object({
271
+ bold: z.boolean().optional().describe('Bold text'),
272
+ italic: z.boolean().optional().describe('Italic text'),
273
+ color: z.string().optional().describe("Font color (e.g., '#FF0000')"),
274
+ size: z.number().optional().describe('Font size'),
275
+ })
276
+ .optional(),
277
+ numberFormat: z.string().optional().describe("Number format (e.g., '0.00%', 'mm/dd/yyyy')"),
278
+ })
279
+ .describe('Formatting to apply'),
280
+ },
281
+ async ({ worksheet, range, format }) => {
282
+ return graphRequest(`/workbook/worksheets('${worksheet}')/range(address='${range}')/format`, {
283
+ method: 'PATCH',
284
+ body: JSON.stringify(format),
285
+ });
286
+ }
287
+ );
288
+
289
+ server.tool(
290
+ 'sort-range',
291
+ {
292
+ worksheet: z.string().default('Sheet1').describe('Worksheet name'),
293
+ range: z.string().describe("Range address (e.g., 'A1:B5')"),
294
+ sortFields: z
295
+ .array(
296
+ z.object({
297
+ key: z.number().describe('Column index to sort by (zero-based)'),
298
+ sortOn: z
299
+ .string()
300
+ .optional()
301
+ .describe("Sorting criteria (e.g., 'Value', 'CellColor', 'FontColor', 'Icon')"),
302
+ ascending: z.boolean().optional().describe('Sort in ascending order (default: true)'),
303
+ color: z
304
+ .object({
305
+ color: z.string().describe('HTML color code'),
306
+ type: z.string().describe("Color type (e.g., 'Background', 'Font')"),
307
+ })
308
+ .optional()
309
+ .describe('Color information for sorting by color'),
310
+ dataOption: z
311
+ .string()
312
+ .optional()
313
+ .describe("Data option for sorting (e.g., 'Normal', 'TextAsNumber')"),
314
+ icon: z
315
+ .object({
316
+ set: z.string().describe('Icon set name'),
317
+ index: z.number().describe('Icon index'),
318
+ })
319
+ .optional()
320
+ .describe('Icon information for sorting by icon'),
321
+ })
322
+ )
323
+ .describe('Fields to sort by'),
324
+ matchCase: z.boolean().optional().describe('Whether the sort is case-sensitive'),
325
+ hasHeaders: z.boolean().optional().describe('Whether the range has headers (default: false)'),
326
+ orientation: z.string().optional().describe("Sort orientation ('Rows' or 'Columns')"),
327
+ method: z
328
+ .string()
329
+ .optional()
330
+ .describe("Sort method for Chinese characters ('PinYin' or 'StrokeCount')"),
331
+ },
332
+ async ({ worksheet, range, sortFields, matchCase, hasHeaders, orientation, method }) => {
333
+ const body = {
334
+ fields: sortFields,
335
+ };
336
+
337
+ if (matchCase !== undefined) body.matchCase = matchCase;
338
+ if (hasHeaders !== undefined) body.hasHeaders = hasHeaders;
339
+ if (orientation) body.orientation = orientation;
340
+ if (method) body.method = method;
341
+
342
+ return graphRequest(
343
+ `/workbook/worksheets('${worksheet}')/range(address='${range}')/sort/apply`,
344
+ {
345
+ method: 'POST',
346
+ body: JSON.stringify(body),
347
+ }
348
+ );
349
+ }
350
+ );
351
+
352
+ server.tool(
353
+ 'create-table',
354
+ {
355
+ worksheet: z.string().default('Sheet1').describe('Worksheet name'),
356
+ range: z.string().describe("Range address (e.g., 'A1:B5')"),
357
+ hasHeaders: z.boolean().optional().describe('Whether the range has headers'),
358
+ tableName: z.string().optional().describe('Name for the new table'),
359
+ },
360
+ async ({ worksheet, range, hasHeaders = true, tableName }) => {
361
+ const body = {
362
+ address: range,
363
+ hasHeaders,
364
+ };
365
+
366
+ if (tableName) {
367
+ body.name = tableName;
368
+ }
369
+
370
+ return graphRequest(`/workbook/worksheets('${worksheet}')/tables/add`, {
371
+ method: 'POST',
372
+ body: JSON.stringify(body),
373
+ });
374
+ }
375
+ );
376
+
377
+ server.tool(
378
+ 'get-range',
379
+ {
380
+ worksheet: z.string().default('Sheet1').describe('Worksheet name'),
381
+ range: z.string().describe("Range address (e.g., 'A1:B5')"),
382
+ },
383
+ async ({ worksheet, range }) => {
384
+ return graphRequest(`/workbook/worksheets('${worksheet}')/range(address='${range}')`, {
385
+ method: 'GET',
386
+ });
387
+ }
388
+ );
389
+
390
+ server.tool('list-worksheets', {}, async () => {
391
+ return graphRequest('/workbook/worksheets', {
392
+ method: 'GET',
393
+ });
394
+ });
395
+
396
+ server.tool('close-session', {}, async () => {
397
+ if (!sessionId) {
398
+ return {
399
+ content: [
400
+ {
401
+ type: 'text',
402
+ text: JSON.stringify({ message: 'No active session' }),
403
+ },
404
+ ],
405
+ };
406
+ }
407
+
408
+ try {
409
+ const accessToken = await authManager.getToken();
410
+ const response = await fetch(
411
+ `https://graph.microsoft.com/v1.0/me/drive/root:${filePath}:/workbook/closeSession`,
412
+ {
413
+ method: 'POST',
414
+ headers: {
415
+ Authorization: `Bearer ${accessToken}`,
416
+ 'Content-Type': 'application/json',
417
+ 'workbook-session-id': sessionId,
418
+ },
419
+ }
420
+ );
421
+
422
+ if (response.ok) {
423
+ sessionId = null;
424
+ return {
425
+ content: [
426
+ {
427
+ type: 'text',
428
+ text: JSON.stringify({ message: 'Session closed successfully' }),
429
+ },
430
+ ],
431
+ };
432
+ } else {
433
+ throw new Error(`Failed to close session: ${response.status}`);
434
+ }
435
+ } catch (error) {
436
+ logger.error(`Error closing session: ${error}`);
437
+ return {
438
+ content: [
439
+ {
440
+ type: 'text',
441
+ text: JSON.stringify({ error: 'Failed to close session' }),
442
+ },
443
+ ],
444
+ };
445
+ }
446
+ });
447
+
448
+ server.tool(
449
+ 'delete-chart',
450
+ {
451
+ worksheet: z.string().default('Sheet1').describe('Worksheet name'),
452
+ chartName: z.string().describe('The name of the chart to delete'),
453
+ },
454
+ async ({ worksheet, chartName }) => {
455
+ return graphRequest(`/workbook/worksheets('${worksheet}')/charts('${chartName}')`, {
456
+ method: 'DELETE',
457
+ });
458
+ }
459
+ );
460
+
461
+ server.tool(
462
+ 'get-charts',
463
+ {
464
+ worksheet: z.string().default('Sheet1').describe('Worksheet name'),
465
+ },
466
+ async ({ worksheet }) => {
467
+ return graphRequest(`/workbook/worksheets('${worksheet}')/charts`, {
468
+ method: 'GET',
469
+ });
470
+ }
471
+ );
472
+
473
+ async function main() {
474
+ try {
475
+ if (args.v) {
476
+ enableConsoleLogging();
477
+ }
478
+
479
+ logger.info('Microsoft 365 MCP Server starting...');
480
+
481
+ if (args.login) {
482
+ await authManager.acquireTokenByDeviceCode();
483
+ logger.info('Login completed, proceeding with session creation');
484
+ process.exit();
485
+ }
486
+
487
+ if (args.testLogin) {
488
+ try {
489
+ logger.info('Testing login...');
490
+ const token = await authManager.getToken();
491
+ if (token) {
492
+ logger.info('Login test successful');
493
+
494
+ console.log(JSON.stringify({ success: true, message: 'Login successful' }));
495
+ } else {
496
+ logger.error('Login test failed - no token received');
497
+ console.log(
498
+ JSON.stringify({ success: false, message: 'Login failed - no token received' })
499
+ );
500
+ }
501
+ } catch (error) {
502
+ logger.error(`Login test failed: ${error.message}`);
503
+ console.log(JSON.stringify({ success: false, message: `Login failed: ${error.message}` }));
504
+ }
505
+ process.exit(0);
506
+ }
507
+
508
+ await createSession();
509
+
510
+ const transport = new StdioServerTransport();
511
+ await server.connect(transport);
512
+ } catch (error) {
513
+ logger.error(`Startup error: ${error}`);
514
+ process.exit(1);
515
+ }
516
+ }
517
+
518
+ main();
package/logger.mjs ADDED
@@ -0,0 +1,44 @@
1
+ import winston from 'winston';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+
7
+ const logsDir = path.join(__dirname, 'logs');
8
+ import fs from 'fs';
9
+
10
+ if (!fs.existsSync(logsDir)) {
11
+ fs.mkdirSync(logsDir);
12
+ }
13
+
14
+ const logger = winston.createLogger({
15
+ level: process.env.LOG_LEVEL || 'info',
16
+ format: winston.format.combine(
17
+ winston.format.timestamp({
18
+ format: 'YYYY-MM-DD HH:mm:ss',
19
+ }),
20
+ winston.format.printf(({ level, message, timestamp }) => {
21
+ return `${timestamp} ${level.toUpperCase()}: ${message}`;
22
+ })
23
+ ),
24
+ transports: [
25
+ new winston.transports.File({
26
+ filename: path.join(logsDir, 'error.log'),
27
+ level: 'error',
28
+ }),
29
+ new winston.transports.File({
30
+ filename: path.join(logsDir, 'mcp-server.log'),
31
+ }),
32
+ ],
33
+ });
34
+
35
+ export const enableConsoleLogging = () => {
36
+ logger.add(
37
+ new winston.transports.Console({
38
+ format: winston.format.combine(winston.format.colorize(), winston.format.simple()),
39
+ silent: process.env.SILENT === 'true',
40
+ })
41
+ );
42
+ };
43
+
44
+ export default logger;
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@softeria/ms-365-mcp-server",
3
+ "version": "0.1.9",
4
+ "description": "Microsoft 365 MCP Server",
5
+ "type": "module",
6
+ "main": "index.mjs",
7
+ "bin": {
8
+ "ms-365-mcp-server": "index.mjs"
9
+ },
10
+ "scripts": {
11
+ "test": "vitest run",
12
+ "test:watch": "vitest",
13
+ "start": "node index.mjs",
14
+ "format": "prettier --write \"**/*.{js,mjs,json,md}\"",
15
+ "release": "node bin/release.mjs"
16
+ },
17
+ "keywords": [
18
+ "microsoft",
19
+ "365",
20
+ "mcp",
21
+ "server"
22
+ ],
23
+ "author": "",
24
+ "license": "MIT",
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "dependencies": {
29
+ "@azure/msal-node": "^2.1.0",
30
+ "@modelcontextprotocol/sdk": "^1.8.0",
31
+ "commander": "^11.1.0",
32
+ "keytar": "^7.9.0",
33
+ "winston": "^3.17.0",
34
+ "zod": "^3.24.2"
35
+ },
36
+ "devDependencies": {
37
+ "prettier": "^3.5.3",
38
+ "vitest": "^3.1.1"
39
+ }
40
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ vi.mock('commander', () => {
3
+ const mockCommand = {
4
+ name: vi.fn().mockReturnThis(),
5
+ description: vi.fn().mockReturnThis(),
6
+ version: vi.fn().mockReturnThis(),
7
+ option: vi.fn().mockReturnThis(),
8
+ parse: vi.fn(),
9
+ opts: vi.fn().mockReturnValue({ file: 'test.xlsx' }),
10
+ };
11
+
12
+ return {
13
+ Command: vi.fn(() => mockCommand),
14
+ };
15
+ });
16
+
17
+ vi.mock('../auth.mjs', () => {
18
+ return {
19
+ default: vi.fn().mockImplementation(() => ({
20
+ getToken: vi.fn().mockResolvedValue('mock-token'),
21
+ logout: vi.fn().mockResolvedValue(true),
22
+ })),
23
+ };
24
+ });
25
+ vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
26
+ const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {});
27
+ import { Command } from 'commander';
28
+ import AuthManager from '../auth.mjs';
29
+ import { parseArgs } from '../cli.mjs';
30
+
31
+ describe('CLI Module', () => {
32
+ beforeEach(() => {
33
+ vi.clearAllMocks();
34
+ });
35
+
36
+ afterEach(() => {
37
+ vi.resetAllMocks();
38
+ });
39
+
40
+ describe('parseArgs', () => {
41
+ it('should return command options', () => {
42
+ const result = parseArgs();
43
+ expect(result).toEqual({ file: 'test.xlsx' });
44
+ });
45
+ });
46
+ });
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ global.fetch = vi.fn();
3
+
4
+ describe('Graph API Functions', () => {
5
+ beforeEach(() => {
6
+ vi.clearAllMocks();
7
+ global.fetch.mockImplementation(async () => ({
8
+ ok: true,
9
+ status: 200,
10
+ json: async () => ({ value: 'test data' }),
11
+ text: async () => 'Error text',
12
+ }));
13
+ });
14
+
15
+ afterEach(() => {
16
+ vi.resetAllMocks();
17
+ });
18
+
19
+ describe('createSession', () => {
20
+ async function createSession(filePath, token) {
21
+ try {
22
+ const response = await fetch(
23
+ `https://graph.microsoft.com/v1.0/me/drive/root:${filePath}:/workbook/createSession`,
24
+ {
25
+ method: 'POST',
26
+ headers: {
27
+ Authorization: `Bearer ${token}`,
28
+ 'Content-Type': 'application/json',
29
+ },
30
+ body: JSON.stringify({ persistChanges: true }),
31
+ }
32
+ );
33
+
34
+ if (!response.ok) {
35
+ return null;
36
+ }
37
+
38
+ const result = await response.json();
39
+ return result.id;
40
+ } catch (error) {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ it('should create a session successfully', async () => {
46
+ global.fetch.mockImplementationOnce(async () => ({
47
+ ok: true,
48
+ status: 200,
49
+ json: async () => ({ id: 'session-123' }),
50
+ }));
51
+
52
+ const result = await createSession('/test.xlsx', 'mock-token');
53
+
54
+ expect(result).toBe('session-123');
55
+ expect(global.fetch).toHaveBeenCalledWith(
56
+ 'https://graph.microsoft.com/v1.0/me/drive/root:/test.xlsx:/workbook/createSession',
57
+ expect.objectContaining({
58
+ method: 'POST',
59
+ headers: expect.objectContaining({
60
+ Authorization: 'Bearer mock-token',
61
+ }),
62
+ })
63
+ );
64
+ });
65
+
66
+ it('should return null if session creation fails', async () => {
67
+ global.fetch.mockImplementationOnce(async () => ({
68
+ ok: false,
69
+ status: 400,
70
+ text: async () => 'Bad request',
71
+ }));
72
+
73
+ const result = await createSession('/test.xlsx', 'mock-token');
74
+
75
+ expect(result).toBeNull();
76
+ });
77
+
78
+ it('should return null if an error is thrown', async () => {
79
+ global.fetch.mockImplementationOnce(() => {
80
+ throw new Error('Network error');
81
+ });
82
+
83
+ const result = await createSession('/test.xlsx', 'mock-token');
84
+
85
+ expect(result).toBeNull();
86
+ });
87
+ });
88
+ });
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+
5
+ describe('Integration Tests', () => {
6
+ it('should have correct package.json configuration', () => {
7
+ const packagePath = path.resolve(process.cwd(), 'package.json');
8
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
9
+
10
+ expect(packageJson).toHaveProperty('type', 'module');
11
+ expect(packageJson).toHaveProperty('bin.ms-365-mcp-server');
12
+ expect(packageJson.bin['ms-365-mcp-server']).toEqual('index.mjs');
13
+ });
14
+
15
+ it('should have all required dependencies', () => {
16
+ const packagePath = path.resolve(process.cwd(), 'package.json');
17
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
18
+
19
+ const requiredDependencies = [
20
+ '@azure/msal-node',
21
+ '@modelcontextprotocol/sdk',
22
+ 'commander',
23
+ 'keytar',
24
+ 'zod',
25
+ ];
26
+
27
+ requiredDependencies.forEach((dep) => {
28
+ expect(packageJson.dependencies).toHaveProperty(dep);
29
+ });
30
+ });
31
+
32
+ it('should have all required files', () => {
33
+ const requiredFiles = ['index.mjs', 'auth.mjs', 'cli.mjs', 'package.json', 'README.md'];
34
+
35
+ requiredFiles.forEach((file) => {
36
+ const filePath = path.resolve(process.cwd(), file);
37
+ expect(fs.existsSync(filePath)).toBe(true);
38
+ });
39
+ });
40
+ });
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { z } from 'zod';
3
+
4
+ vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({
5
+ McpServer: vi.fn(() => ({
6
+ tool: vi.fn(),
7
+ connect: vi.fn().mockResolvedValue(undefined),
8
+ name: 'TestServer',
9
+ version: '0.1.1',
10
+ })),
11
+ }));
12
+ vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
13
+
14
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
15
+
16
+ describe('MCP Server', () => {
17
+ let server;
18
+
19
+ beforeEach(() => {
20
+ vi.clearAllMocks();
21
+ server = new McpServer();
22
+ });
23
+
24
+ it('should be created with proper configuration', () => {
25
+ expect(McpServer).toHaveBeenCalled();
26
+ expect(server).toBeDefined();
27
+ });
28
+
29
+ it('should be able to register tools', () => {
30
+ server.tool('test-tool', { param: z.string() }, async () => {});
31
+ expect(server.tool).toHaveBeenCalledWith(
32
+ 'test-tool',
33
+ { param: expect.any(Object) },
34
+ expect.any(Function)
35
+ );
36
+ });
37
+ });
@@ -0,0 +1,7 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ describe('Simple Test Suite', () => {
4
+ it('should pass a basic test', () => {
5
+ expect(1 + 1).toBe(2);
6
+ });
7
+ });
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ },
8
+ });