@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.
- package/.github/workflows/npm-publish.yml +33 -0
- package/.prettierrc +7 -0
- package/LICENSE +21 -0
- package/README.md +70 -0
- package/auth.mjs +153 -0
- package/cli.mjs +21 -0
- package/index.mjs +518 -0
- package/logger.mjs +44 -0
- package/package.json +40 -0
- package/test/cli.test.js +46 -0
- package/test/graph-api.test.js +88 -0
- package/test/integration.test.js +40 -0
- package/test/mcp-server.test.js +37 -0
- package/test/simple.test.js +7 -0
- package/vitest.config.js +8 -0
|
@@ -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
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
|
+
[]()
|
|
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
|
+
}
|
package/test/cli.test.js
ADDED
|
@@ -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
|
+
});
|