@softeria/ms-365-mcp-server 0.2.2 → 0.3.1

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,231 @@
1
+ import logger from './logger.mjs';
2
+ import {
3
+ buildParameterSchemas,
4
+ buildRequestUrl,
5
+ findPathAndOperation,
6
+ isMethodWithBody,
7
+ loadOpenApiSpec,
8
+ } from './openapi-helpers.mjs';
9
+
10
+ export const TARGET_ENDPOINTS = [
11
+ {
12
+ pathPattern: '/me/messages',
13
+ method: 'get',
14
+ toolName: 'list-mail-messages',
15
+ },
16
+ {
17
+ pathPattern: '/me/mailFolders',
18
+ method: 'get',
19
+ toolName: 'list-mail-folders',
20
+ },
21
+ {
22
+ pathPattern: '/me/mailFolders/{mailFolder-id}/messages',
23
+ method: 'get',
24
+ toolName: 'list-mail-folder-messages',
25
+ },
26
+ {
27
+ pathPattern: '/me/messages/{message-id}',
28
+ method: 'get',
29
+ toolName: 'get-mail-message',
30
+ },
31
+ {
32
+ pathPattern: '/me/events',
33
+ method: 'get',
34
+ toolName: 'list-calendar-events',
35
+ },
36
+ {
37
+ pathPattern: '/me/events/{event-id}',
38
+ method: 'get',
39
+ toolName: 'get-calendar-event',
40
+ },
41
+ {
42
+ pathPattern: '/me/events',
43
+ method: 'post',
44
+ toolName: 'create-calendar-event',
45
+ },
46
+ {
47
+ pathPattern: '/me/events/{event-id}',
48
+ method: 'patch',
49
+ toolName: 'update-calendar-event',
50
+ },
51
+ {
52
+ pathPattern: '/me/events/{event-id}',
53
+ method: 'delete',
54
+ toolName: 'delete-calendar-event',
55
+ },
56
+ {
57
+ pathPattern: '/me/calendarView',
58
+ method: 'get',
59
+ toolName: 'get-calendar-view',
60
+ },
61
+ {
62
+ pathPattern: '/users/{user-id}/drive',
63
+ method: 'get',
64
+ toolName: 'get-user-drive',
65
+ },
66
+ {
67
+ pathPattern: '/drives',
68
+ method: 'get',
69
+ toolName: 'list-drives',
70
+ },
71
+ {
72
+ pathPattern: '/drives/{drive-id}/root',
73
+ method: 'get',
74
+ toolName: 'get-drive-root-item',
75
+ },
76
+ {
77
+ pathPattern: '/drives/{drive-id}/root',
78
+ method: 'get',
79
+ toolName: 'get-root-folder',
80
+ },
81
+ {
82
+ pathPattern: '/drives/{drive-id}/items/{driveItem-id}/children',
83
+ method: 'get',
84
+ toolName: 'list-folder-files',
85
+ },
86
+ {
87
+ pathPattern: '/drives/{drive-id}/items/{driveItem-id}/children',
88
+ method: 'post',
89
+ toolName: 'create-item-in-folder',
90
+ },
91
+ {
92
+ pathPattern: '/drives/{drive-id}/items/{driveItem-id}/children/{driveItem-id1}/content',
93
+ method: 'get',
94
+ toolName: 'download-file-content',
95
+ },
96
+ {
97
+ pathPattern: '/drives/{drive-id}/items/{driveItem-id}',
98
+ method: 'delete',
99
+ toolName: 'delete-file',
100
+ },
101
+ {
102
+ pathPattern: '/drives/{drive-id}/items/{driveItem-id}',
103
+ method: 'patch',
104
+ toolName: 'update-file-metadata',
105
+ },
106
+ {
107
+ pathPattern:
108
+ '/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets/{workbookWorksheet-id}/charts/add',
109
+ method: 'post',
110
+ toolName: 'create-chart',
111
+ isExcelOp: true,
112
+ },
113
+ {
114
+ pathPattern:
115
+ '/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets/{workbookWorksheet-id}/range()/format',
116
+ method: 'patch',
117
+ toolName: 'format-range',
118
+ isExcelOp: true,
119
+ },
120
+ {
121
+ pathPattern:
122
+ '/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets/{workbookWorksheet-id}/range()/sort',
123
+ method: 'patch',
124
+ toolName: 'sort-range',
125
+ isExcelOp: true,
126
+ },
127
+ {
128
+ pathPattern:
129
+ "/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets/{workbookWorksheet-id}/range(address='{address}')",
130
+ method: 'get',
131
+ toolName: 'get-range',
132
+ isExcelOp: true,
133
+ },
134
+ {
135
+ pathPattern: '/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets',
136
+ method: 'get',
137
+ toolName: 'list-worksheets',
138
+ isExcelOp: true,
139
+ },
140
+ ];
141
+
142
+ export async function registerDynamicTools(server, graphClient) {
143
+ try {
144
+ const openapi = loadOpenApiSpec();
145
+ logger.info('Generating dynamic tools from OpenAPI spec...');
146
+
147
+ for (const endpoint of TARGET_ENDPOINTS) {
148
+ const result = findPathAndOperation(openapi, endpoint.pathPattern, endpoint.method);
149
+ if (!result) continue;
150
+
151
+ const { operation } = result;
152
+
153
+ logger.info(
154
+ `Creating tool ${endpoint.toolName} for ${endpoint.method.toUpperCase()} ${endpoint.pathPattern}`
155
+ );
156
+
157
+ const paramsSchema = buildParameterSchemas(endpoint, operation);
158
+
159
+ if (endpoint.hasCustomParams) {
160
+ if (endpoint.toolName === 'upload-file') {
161
+ paramsSchema.content = z.string().describe('File content to upload');
162
+ paramsSchema.contentType = z
163
+ .string()
164
+ .optional()
165
+ .describe('Content type of the file (e.g., "application/pdf", "image/jpeg")');
166
+ } else if (endpoint.toolName === 'create-folder') {
167
+ paramsSchema.name = z.string().describe('Name of the folder to create');
168
+ paramsSchema.description = z.string().optional().describe('Description of the folder');
169
+ }
170
+ }
171
+
172
+ const pathParams = endpoint.pathPattern.match(/\{([^}]+)}/g) || [];
173
+
174
+ const handler = async (params) => {
175
+ if (endpoint.isExcelOp && !params.filePath) {
176
+ return {
177
+ content: [
178
+ {
179
+ type: 'text',
180
+ text: JSON.stringify({
181
+ error: 'filePath parameter is required for Excel operations',
182
+ }),
183
+ },
184
+ ],
185
+ };
186
+ }
187
+
188
+ const options = {
189
+ method: endpoint.method.toUpperCase(),
190
+ };
191
+
192
+ if (endpoint.isExcelOp) {
193
+ options.excelFile = params.filePath;
194
+ }
195
+
196
+ if (endpoint.toolName === 'download-file') {
197
+ options.rawResponse = true;
198
+ }
199
+
200
+ const url = buildRequestUrl(endpoint.pathPattern, params, pathParams, operation.parameters);
201
+
202
+ if (endpoint.toolName === 'upload-file' && params.content) {
203
+ options.body = params.content;
204
+ options.headers = {
205
+ 'Content-Type': params.contentType || 'application/octet-stream',
206
+ };
207
+ } else if (endpoint.toolName === 'create-folder' && params.name) {
208
+ options.body = JSON.stringify({
209
+ name: params.name,
210
+ folder: {},
211
+ '@microsoft.graph.conflictBehavior': 'rename',
212
+ ...(params.description && { description: params.description }),
213
+ });
214
+ options.headers = {
215
+ 'Content-Type': 'application/json',
216
+ };
217
+ } else if (isMethodWithBody(endpoint.method.toLowerCase()) && params.body) {
218
+ options.body = JSON.stringify(params.body);
219
+ }
220
+
221
+ return graphClient.graphRequest(url, options);
222
+ };
223
+
224
+ server.tool(endpoint.toolName, paramsSchema, handler);
225
+ }
226
+ logger.info(`Dynamic tools registration complete.`);
227
+ } catch (error) {
228
+ logger.error('Error registering dynamic tools:', error);
229
+ throw error;
230
+ }
231
+ }
@@ -0,0 +1,187 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import yaml from 'js-yaml';
4
+ import { fileURLToPath } from 'url';
5
+ import { z } from 'zod';
6
+ import logger from './logger.mjs';
7
+ import { createFriendlyParamName, registerParamMapping } from './param-mapper.mjs';
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = path.dirname(__filename);
11
+
12
+ export const OPENAPI_PATH = path.join(__dirname, '..', 'openapi', 'openapi.yaml');
13
+
14
+ export function loadOpenApiSpec() {
15
+ try {
16
+ logger.info('Loading OpenAPI spec...');
17
+ const openapiContent = fs.readFileSync(OPENAPI_PATH, 'utf8');
18
+ return yaml.load(openapiContent);
19
+ } catch (error) {
20
+ logger.error('Error loading OpenAPI spec:', error);
21
+ throw error;
22
+ }
23
+ }
24
+
25
+ export function mapToZodType(schema) {
26
+ if (!schema) return z.any();
27
+
28
+ if (schema.$ref) {
29
+ const refName = schema.$ref.split('/').pop();
30
+ if (refName.toLowerCase().includes('string')) return z.string();
31
+ if (refName.toLowerCase().includes('int') || refName.toLowerCase().includes('number'))
32
+ return z.number();
33
+ if (refName.toLowerCase().includes('boolean')) return z.boolean();
34
+ if (refName.toLowerCase().includes('date')) return z.string();
35
+ if (refName.toLowerCase().includes('object')) return z.object({}).passthrough();
36
+ if (refName.toLowerCase().includes('array')) return z.array(z.any());
37
+
38
+ return z.object({}).passthrough();
39
+ }
40
+
41
+ switch (schema.type) {
42
+ case 'string':
43
+ const stringSchema = z.string();
44
+ if (schema.format === 'date-time') return stringSchema;
45
+ if (schema.enum) return z.enum(schema.enum);
46
+ return stringSchema;
47
+ case 'integer':
48
+ case 'number':
49
+ return z.number();
50
+ case 'boolean':
51
+ return z.boolean();
52
+ case 'array':
53
+ return z.array(mapToZodType(schema.items || {}));
54
+ case 'object':
55
+ const properties = schema.properties || {};
56
+ const shape = {};
57
+
58
+ Object.entries(properties).forEach(([key, prop]) => {
59
+ shape[key] = mapToZodType(prop);
60
+ if (schema.required && schema.required.includes(key)) {
61
+ } else {
62
+ shape[key] = shape[key].optional();
63
+ }
64
+ });
65
+
66
+ return z.object(shape).passthrough();
67
+ default:
68
+ return z.any();
69
+ }
70
+ }
71
+
72
+ export function processParameter(parameter) {
73
+ const zodSchema = mapToZodType(parameter.schema);
74
+
75
+ let schema = parameter.description ? zodSchema.describe(parameter.description) : zodSchema;
76
+
77
+ if (!parameter.required) {
78
+ schema = schema.optional();
79
+ }
80
+
81
+ return schema;
82
+ }
83
+
84
+ export function findPathAndOperation(openapi, pathPattern, method) {
85
+ const path = openapi.paths[pathPattern];
86
+
87
+ if (!path) {
88
+ logger.warn(`Path ${pathPattern} not found in OpenAPI spec`);
89
+ return null;
90
+ }
91
+
92
+ const operation = path[method.toLowerCase()];
93
+
94
+ if (!operation) {
95
+ logger.warn(`Method ${method} not found for path ${pathPattern}`);
96
+ return null;
97
+ }
98
+
99
+ return { path, operation };
100
+ }
101
+
102
+ export function isMethodWithBody(method) {
103
+ return ['post', 'put', 'patch'].includes(method);
104
+ }
105
+
106
+ export function buildParameterSchemas(endpoint, operation) {
107
+ const paramsSchema = {};
108
+
109
+ const pathParams = endpoint.pathPattern.match(/\{([^}]+)}/g) || [];
110
+ pathParams.forEach((param) => {
111
+ const paramName = param.slice(1, -1);
112
+ paramsSchema[paramName] = z.string().describe(`Path parameter: ${paramName}`);
113
+ });
114
+
115
+ if (operation.parameters) {
116
+ operation.parameters.forEach((param) => {
117
+ if (param.in === 'query') {
118
+ if (!pathParams.includes(`{${param.name}}`)) {
119
+ const friendlyName = createFriendlyParamName(param.name);
120
+ registerParamMapping(endpoint.toolName, friendlyName, param.name);
121
+ paramsSchema[friendlyName] = processParameter(param);
122
+ }
123
+ }
124
+ });
125
+ }
126
+
127
+ if (isMethodWithBody(endpoint.method) && operation.requestBody) {
128
+ const contentType =
129
+ operation.requestBody.content?.['application/json'] ||
130
+ operation.requestBody.content?.['*/*'] ||
131
+ {};
132
+
133
+ if (contentType.schema) {
134
+ paramsSchema.body = z
135
+ .object({})
136
+ .passthrough()
137
+ .describe(operation.requestBody.description || 'Request body');
138
+ }
139
+ }
140
+
141
+ if (endpoint.isExcelOp) {
142
+ paramsSchema.filePath = z.string().describe('Path to the Excel file in OneDrive');
143
+
144
+ if (endpoint.pathPattern.includes('range(address=')) {
145
+ paramsSchema.address = z.string().describe('Excel range address (e.g., "A1:B10")');
146
+ }
147
+ }
148
+
149
+ return paramsSchema;
150
+ }
151
+
152
+ export function buildRequestUrl(baseUrl, params, pathParams, queryParamDefs) {
153
+ let url = baseUrl;
154
+
155
+ pathParams.forEach((param) => {
156
+ const paramName = param.slice(1, -1);
157
+ url = url.replace(param, params[paramName]);
158
+ });
159
+
160
+ if (url.includes("range(address='{address}')") && params.address) {
161
+ url = url.replace('{address}', encodeURIComponent(params.address));
162
+ }
163
+
164
+ const queryParams = [];
165
+
166
+ if (queryParamDefs) {
167
+ queryParamDefs.forEach((param) => {
168
+ if (param.in === 'query') {
169
+ const friendlyName = createFriendlyParamName(param.name);
170
+
171
+ if (params[friendlyName] !== undefined) {
172
+ if (Array.isArray(params[friendlyName])) {
173
+ queryParams.push(`${param.name}=${params[friendlyName].join(',')}`);
174
+ } else {
175
+ queryParams.push(`${param.name}=${encodeURIComponent(params[friendlyName])}`);
176
+ }
177
+ }
178
+ }
179
+ });
180
+ }
181
+
182
+ if (queryParams.length > 0) {
183
+ url += '?' + queryParams.join('&');
184
+ }
185
+
186
+ return url;
187
+ }
@@ -0,0 +1,30 @@
1
+ const paramMappings = new Map();
2
+
3
+ export function createFriendlyParamName(originalName) {
4
+ if (originalName.startsWith('$')) {
5
+ return originalName.substring(1);
6
+ }
7
+
8
+ return originalName;
9
+ }
10
+
11
+ export function registerParamMapping(toolName, friendlyName, originalName) {
12
+ const key = `${toolName}:${friendlyName}`;
13
+ paramMappings.set(key, originalName);
14
+ }
15
+
16
+ export function getOriginalParamName(toolName, friendlyName) {
17
+ const key = `${toolName}:${friendlyName}`;
18
+ return paramMappings.get(key) || friendlyName;
19
+ }
20
+
21
+ export function transformParamsToOriginal(toolName, params) {
22
+ const result = {};
23
+
24
+ for (const [friendlyName, value] of Object.entries(params)) {
25
+ const originalName = getOriginalParamName(toolName, friendlyName);
26
+ result[originalName] = value;
27
+ }
28
+
29
+ return result;
30
+ }
package/src/server.mjs CHANGED
@@ -1,11 +1,8 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import logger, { enableConsoleLogging } from './logger.mjs';
4
- import { registerExcelTools } from './excel-tools.mjs';
5
4
  import { registerAuthTools } from './auth-tools.mjs';
6
- import { registerFilesTools } from './files-tools.mjs';
7
- import { registerCalendarTools } from './calendar-tools.mjs';
8
- import { registerMailTools } from './mail-tools.mjs';
5
+ import { registerDynamicTools } from './dynamic-tools.mjs';
9
6
  import GraphClient from './graph-client.mjs';
10
7
 
11
8
  class MicrosoftGraphServer {
@@ -23,10 +20,7 @@ class MicrosoftGraphServer {
23
20
  });
24
21
 
25
22
  registerAuthTools(this.server, this.authManager);
26
- registerFilesTools(this.server, this.graphClient);
27
- registerExcelTools(this.server, this.graphClient);
28
- registerCalendarTools(this.server, this.graphClient);
29
- registerMailTools(this.server, this.graphClient);
23
+ await registerDynamicTools(this.server, this.graphClient);
30
24
  }
31
25
 
32
26
  async start() {
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
2
+
3
+ // Mock Zod
4
+ vi.mock('zod', () => {
5
+ const mockZod = {
6
+ boolean: () => ({
7
+ default: () => ({
8
+ describe: () => 'mocked-zod-boolean'
9
+ })
10
+ }),
11
+ object: () => ({
12
+ strict: () => 'mocked-zod-object'
13
+ })
14
+ };
15
+ return { z: mockZod };
16
+ });
17
+
18
+ import { registerAuthTools } from '../src/auth-tools.mjs';
19
+
20
+ describe('Auth Tools', () => {
21
+ let server;
22
+ let authManager;
23
+ let loginTool;
24
+
25
+ beforeEach(() => {
26
+ loginTool = vi.fn();
27
+
28
+ server = {
29
+ tool: vi.fn((name, schema, handler) => {
30
+ if (name === 'login') {
31
+ loginTool = handler;
32
+ }
33
+ })
34
+ };
35
+
36
+ authManager = {
37
+ testLogin: vi.fn(),
38
+ acquireTokenByDeviceCode: vi.fn()
39
+ };
40
+
41
+ registerAuthTools(server, authManager);
42
+ });
43
+
44
+ describe('login tool', () => {
45
+ it('should check if already logged in when force=false', async () => {
46
+ authManager.testLogin.mockResolvedValue({
47
+ success: true,
48
+ userData: { displayName: 'Test User' }
49
+ });
50
+
51
+ const result = await loginTool({ force: false });
52
+
53
+ expect(authManager.testLogin).toHaveBeenCalled();
54
+ expect(authManager.acquireTokenByDeviceCode).not.toHaveBeenCalled();
55
+ expect(result.content[0].text).toContain('Already logged in');
56
+ });
57
+
58
+ it('should force login when force=true even if already logged in', async () => {
59
+ authManager.testLogin.mockResolvedValue({
60
+ success: true,
61
+ userData: { displayName: 'Test User' }
62
+ });
63
+
64
+ authManager.acquireTokenByDeviceCode.mockImplementation(callback => {
65
+ callback('Login instructions');
66
+ return Promise.resolve();
67
+ });
68
+
69
+ const result = await loginTool({ force: true });
70
+
71
+ expect(authManager.testLogin).not.toHaveBeenCalled();
72
+ expect(authManager.acquireTokenByDeviceCode).toHaveBeenCalled();
73
+ expect(result.content[0].text).toBe('Login instructions');
74
+ });
75
+
76
+ it('should proceed with login when not already logged in', async () => {
77
+ authManager.testLogin.mockResolvedValue({
78
+ success: false,
79
+ message: 'Not logged in'
80
+ });
81
+
82
+ authManager.acquireTokenByDeviceCode.mockImplementation(callback => {
83
+ callback('Login instructions');
84
+ return Promise.resolve();
85
+ });
86
+
87
+ const result = await loginTool({ force: false });
88
+
89
+ expect(authManager.testLogin).toHaveBeenCalled();
90
+ expect(authManager.acquireTokenByDeviceCode).toHaveBeenCalled();
91
+ expect(result.content[0].text).toBe('Login instructions');
92
+ });
93
+ });
94
+ });