@masonator/m365-mcp 0.2.0 → 0.5.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/dist/__tests__/auth.test.js +24 -3
- package/dist/__tests__/tools/chat.test.js +71 -1
- package/dist/__tests__/tools/mail.test.js +113 -2
- package/dist/__tests__/tools/server-info.test.d.ts +1 -0
- package/dist/__tests__/tools/server-info.test.js +60 -0
- package/dist/__tests__/tools/transcripts.test.js +291 -50
- package/dist/index.js +8 -2
- package/dist/lib/tools/chat.d.ts +5 -0
- package/dist/lib/tools/chat.js +19 -4
- package/dist/lib/tools/mail.d.ts +9 -0
- package/dist/lib/tools/mail.js +92 -4
- package/dist/lib/tools/server-info.d.ts +9 -0
- package/dist/lib/tools/server-info.js +53 -0
- package/dist/lib/tools/transcripts.d.ts +47 -1
- package/dist/lib/tools/transcripts.js +128 -29
- package/package.json +1 -1
|
@@ -2,7 +2,13 @@ import { jest } from '@jest/globals';
|
|
|
2
2
|
import { mkdtempSync, rmSync, readFileSync, writeFileSync, statSync, existsSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
|
-
|
|
5
|
+
// Mock child_process to prevent browser tabs opening during tests
|
|
6
|
+
const mockExecFile = jest.fn();
|
|
7
|
+
jest.unstable_mockModule('node:child_process', () => ({
|
|
8
|
+
execFile: mockExecFile,
|
|
9
|
+
}));
|
|
10
|
+
// Dynamic import AFTER mock registration
|
|
11
|
+
const { getConfigDir, loadTokens, saveTokens, deleteTokens, isTokenExpired, loadAuthConfig, SCOPES, refreshAccessToken, getAccessToken, findAvailablePort, exchangeCodeForTokens, openBrowser, waitForAuthCallback, } = await import('../lib/auth.js');
|
|
6
12
|
const sampleTokens = {
|
|
7
13
|
access_token: 'access-abc-123',
|
|
8
14
|
refresh_token: 'refresh-xyz-789',
|
|
@@ -382,8 +388,23 @@ describe('findAvailablePort', () => {
|
|
|
382
388
|
});
|
|
383
389
|
});
|
|
384
390
|
describe('openBrowser', () => {
|
|
385
|
-
|
|
386
|
-
|
|
391
|
+
afterEach(() => {
|
|
392
|
+
mockExecFile.mockClear();
|
|
393
|
+
});
|
|
394
|
+
it('calls execFile with the correct command for the platform', () => {
|
|
395
|
+
openBrowser('https://example.com');
|
|
396
|
+
// On macOS (darwin), should call 'open'
|
|
397
|
+
if (process.platform === 'darwin') {
|
|
398
|
+
expect(mockExecFile).toHaveBeenCalledWith('open', ['https://example.com']);
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
expect(mockExecFile).toHaveBeenCalled();
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
it('does not throw when execFile throws', () => {
|
|
405
|
+
mockExecFile.mockImplementation(() => {
|
|
406
|
+
throw new Error('Command not found');
|
|
407
|
+
});
|
|
387
408
|
expect(() => openBrowser('https://example.com')).not.toThrow();
|
|
388
409
|
});
|
|
389
410
|
});
|
|
@@ -4,7 +4,7 @@ jest.unstable_mockModule('../../lib/graph.js', () => ({
|
|
|
4
4
|
graphFetch: mockGraphFetch,
|
|
5
5
|
}));
|
|
6
6
|
// Dynamic import AFTER the mock is registered
|
|
7
|
-
const { executeChat } = await import('../../lib/tools/chat.js');
|
|
7
|
+
const { executeChat, stripHtml } = await import('../../lib/tools/chat.js');
|
|
8
8
|
describe('executeChat', () => {
|
|
9
9
|
afterEach(() => {
|
|
10
10
|
mockGraphFetch.mockReset();
|
|
@@ -159,4 +159,74 @@ describe('executeChat', () => {
|
|
|
159
159
|
const result = await executeChat('test-token', { chat_id: 'chat-123' });
|
|
160
160
|
expect(result).toContain('(empty message)');
|
|
161
161
|
});
|
|
162
|
+
it('strips HTML from chat listing preview', async () => {
|
|
163
|
+
mockGraphFetch.mockResolvedValue({
|
|
164
|
+
ok: true,
|
|
165
|
+
data: {
|
|
166
|
+
value: [
|
|
167
|
+
{
|
|
168
|
+
id: 'chat-html',
|
|
169
|
+
topic: 'HTML Preview Chat',
|
|
170
|
+
chatType: 'group',
|
|
171
|
+
lastMessagePreview: {
|
|
172
|
+
body: { content: '<p>Hey <at id="0">Stuart</at>, check this out</p>' },
|
|
173
|
+
createdDateTime: '2025-06-15T10:00:00Z',
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
const result = await executeChat('test-token', {});
|
|
180
|
+
expect(result).toContain('Hey Stuart, check this out');
|
|
181
|
+
expect(result).not.toContain('<p>');
|
|
182
|
+
expect(result).not.toContain('<at');
|
|
183
|
+
});
|
|
184
|
+
it('handles emoji tags in messages', async () => {
|
|
185
|
+
mockGraphFetch.mockResolvedValue({
|
|
186
|
+
ok: true,
|
|
187
|
+
data: {
|
|
188
|
+
value: [
|
|
189
|
+
{
|
|
190
|
+
from: { user: { displayName: 'Alice' } },
|
|
191
|
+
createdDateTime: '2025-06-15T10:30:00Z',
|
|
192
|
+
body: {
|
|
193
|
+
content: '<p>Great work <emoji id="thumbsup" alt="👍"></emoji></p>',
|
|
194
|
+
contentType: 'html',
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
const result = await executeChat('test-token', { chat_id: 'chat-123' });
|
|
201
|
+
expect(result).toContain('Great work 👍');
|
|
202
|
+
expect(result).not.toContain('<emoji');
|
|
203
|
+
expect(result).not.toContain('<p>');
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
describe('stripHtml', () => {
|
|
207
|
+
it('converts br tags to newlines', () => {
|
|
208
|
+
expect(stripHtml('line1<br>line2<br/>line3')).toBe('line1\nline2\nline3');
|
|
209
|
+
});
|
|
210
|
+
it('converts closing p tags to newlines', () => {
|
|
211
|
+
expect(stripHtml('<p>First</p><p>Second</p>')).toBe('First\nSecond');
|
|
212
|
+
});
|
|
213
|
+
it('extracts emoji alt text', () => {
|
|
214
|
+
expect(stripHtml('<emoji id="thumbsup" alt="👍"></emoji>')).toBe('👍');
|
|
215
|
+
expect(stripHtml('<emoji id="smile" alt="😊"/>')).toBe('😊');
|
|
216
|
+
});
|
|
217
|
+
it('preserves text inside at-mention tags', () => {
|
|
218
|
+
expect(stripHtml('<at id="0">Stuart Mason</at>')).toBe('Stuart Mason');
|
|
219
|
+
});
|
|
220
|
+
it('removes attachment tags and content', () => {
|
|
221
|
+
expect(stripHtml('Hello<attachment id="abc">file.pdf</attachment> world')).toBe('Hello world');
|
|
222
|
+
});
|
|
223
|
+
it('decodes HTML entities', () => {
|
|
224
|
+
expect(stripHtml('a & b < c > d "e" f's')).toBe('a & b < c > d "e" f\'s');
|
|
225
|
+
});
|
|
226
|
+
it('collapses excessive newlines', () => {
|
|
227
|
+
expect(stripHtml('<p></p><p></p><p>Content</p>')).toBe('Content');
|
|
228
|
+
});
|
|
229
|
+
it('trims whitespace', () => {
|
|
230
|
+
expect(stripHtml(' <p>Hello</p> ')).toBe('Hello');
|
|
231
|
+
});
|
|
162
232
|
});
|
|
@@ -31,13 +31,25 @@ describe('executeMail', () => {
|
|
|
31
31
|
expect(result).toContain('Importance: high | Read: Yes');
|
|
32
32
|
expect(result).toContain('Here are the meeting notes from today.');
|
|
33
33
|
});
|
|
34
|
-
it('handles search param', async () => {
|
|
34
|
+
it('handles search param without $orderBy', async () => {
|
|
35
35
|
mockGraphFetch.mockResolvedValue({
|
|
36
36
|
ok: true,
|
|
37
37
|
data: { value: [] },
|
|
38
38
|
});
|
|
39
39
|
await executeMail('test-token', { search: 'quarterly report' });
|
|
40
|
-
|
|
40
|
+
const calledPath = mockGraphFetch.mock.calls[0][0];
|
|
41
|
+
expect(calledPath).toContain('$search="quarterly%20report"');
|
|
42
|
+
expect(calledPath).not.toContain('$orderby');
|
|
43
|
+
});
|
|
44
|
+
it('includes $orderBy when no search is provided', async () => {
|
|
45
|
+
mockGraphFetch.mockResolvedValue({
|
|
46
|
+
ok: true,
|
|
47
|
+
data: { value: [] },
|
|
48
|
+
});
|
|
49
|
+
await executeMail('test-token', {});
|
|
50
|
+
const calledPath = mockGraphFetch.mock.calls[0][0];
|
|
51
|
+
expect(calledPath).toContain('$orderby=receivedDateTime desc');
|
|
52
|
+
expect(calledPath).not.toContain('$search');
|
|
41
53
|
});
|
|
42
54
|
it('handles empty results', async () => {
|
|
43
55
|
mockGraphFetch.mockResolvedValue({
|
|
@@ -166,4 +178,103 @@ describe('executeMail', () => {
|
|
|
166
178
|
timezone: false,
|
|
167
179
|
});
|
|
168
180
|
});
|
|
181
|
+
it('includes message ID in list results', async () => {
|
|
182
|
+
mockGraphFetch.mockResolvedValue({
|
|
183
|
+
ok: true,
|
|
184
|
+
data: {
|
|
185
|
+
value: [
|
|
186
|
+
{
|
|
187
|
+
id: 'AAMkAGI2',
|
|
188
|
+
subject: 'Test Email',
|
|
189
|
+
from: { emailAddress: { name: 'Alice', address: 'alice@example.com' } },
|
|
190
|
+
receivedDateTime: '2025-06-15T10:00:00Z',
|
|
191
|
+
bodyPreview: 'Preview text',
|
|
192
|
+
isRead: true,
|
|
193
|
+
importance: 'normal',
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
const result = await executeMail('test-token', {});
|
|
199
|
+
expect(result).toContain('Message ID: AAMkAGI2');
|
|
200
|
+
});
|
|
201
|
+
it('includes id in $select for list mode', async () => {
|
|
202
|
+
mockGraphFetch.mockResolvedValue({
|
|
203
|
+
ok: true,
|
|
204
|
+
data: { value: [] },
|
|
205
|
+
});
|
|
206
|
+
await executeMail('test-token', {});
|
|
207
|
+
const calledPath = mockGraphFetch.mock.calls[0][0];
|
|
208
|
+
expect(calledPath).toContain('$select=id,');
|
|
209
|
+
});
|
|
210
|
+
describe('drill-down mode', () => {
|
|
211
|
+
it('fetches full email body by message_id', async () => {
|
|
212
|
+
mockGraphFetch.mockResolvedValue({
|
|
213
|
+
ok: true,
|
|
214
|
+
data: {
|
|
215
|
+
subject: 'Quarterly Report',
|
|
216
|
+
from: { emailAddress: { name: 'Alice', address: 'alice@example.com' } },
|
|
217
|
+
receivedDateTime: '2025-06-15T10:00:00Z',
|
|
218
|
+
body: { contentType: 'text', content: 'Full body content here.' },
|
|
219
|
+
isRead: true,
|
|
220
|
+
importance: 'high',
|
|
221
|
+
toRecipients: [{ emailAddress: { name: 'Bob', address: 'bob@example.com' } }],
|
|
222
|
+
ccRecipients: [{ emailAddress: { name: 'Carol', address: 'carol@example.com' } }],
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
const result = await executeMail('test-token', { message_id: 'AAMkAGI2' });
|
|
226
|
+
expect(result).toContain('# Quarterly Report');
|
|
227
|
+
expect(result).toContain('From: Alice <alice@example.com>');
|
|
228
|
+
expect(result).toContain('To: Bob <bob@example.com>');
|
|
229
|
+
expect(result).toContain('Cc: Carol <carol@example.com>');
|
|
230
|
+
expect(result).toContain('Full body content here.');
|
|
231
|
+
expect(mockGraphFetch).toHaveBeenCalledWith(expect.stringContaining('/me/messages/AAMkAGI2'), 'test-token', { timezone: false });
|
|
232
|
+
});
|
|
233
|
+
it('strips HTML from email body', async () => {
|
|
234
|
+
mockGraphFetch.mockResolvedValue({
|
|
235
|
+
ok: true,
|
|
236
|
+
data: {
|
|
237
|
+
subject: 'HTML Email',
|
|
238
|
+
from: { emailAddress: { name: 'Alice', address: 'alice@example.com' } },
|
|
239
|
+
body: {
|
|
240
|
+
contentType: 'html',
|
|
241
|
+
content: '<html><body><p>Hello world</p><p>Second paragraph</p></body></html>',
|
|
242
|
+
},
|
|
243
|
+
isRead: true,
|
|
244
|
+
importance: 'normal',
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
const result = await executeMail('test-token', { message_id: 'msg-html' });
|
|
248
|
+
expect(result).toContain('Hello world');
|
|
249
|
+
expect(result).toContain('Second paragraph');
|
|
250
|
+
expect(result).not.toContain('<p>');
|
|
251
|
+
expect(result).not.toContain('<html>');
|
|
252
|
+
});
|
|
253
|
+
it('handles missing body gracefully', async () => {
|
|
254
|
+
mockGraphFetch.mockResolvedValue({
|
|
255
|
+
ok: true,
|
|
256
|
+
data: {
|
|
257
|
+
subject: 'Empty Email',
|
|
258
|
+
from: { emailAddress: { name: 'Alice', address: 'alice@example.com' } },
|
|
259
|
+
isRead: false,
|
|
260
|
+
importance: 'normal',
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
const result = await executeMail('test-token', { message_id: 'msg-empty' });
|
|
264
|
+
expect(result).toContain('# Empty Email');
|
|
265
|
+
expect(result).toContain('(no body)');
|
|
266
|
+
});
|
|
267
|
+
it('returns error when message not found', async () => {
|
|
268
|
+
mockGraphFetch.mockResolvedValue({
|
|
269
|
+
ok: false,
|
|
270
|
+
error: {
|
|
271
|
+
status: 404,
|
|
272
|
+
message: 'Resource not found. Your account may not have an Exchange Online license.',
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
const result = await executeMail('test-token', { message_id: 'nonexistent' });
|
|
276
|
+
expect(result).toContain('Error:');
|
|
277
|
+
expect(result).toContain('not found');
|
|
278
|
+
});
|
|
279
|
+
});
|
|
169
280
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { executeServerInfo } from '../../lib/tools/server-info.js';
|
|
2
|
+
describe('executeServerInfo', () => {
|
|
3
|
+
it('returns server version and metadata', () => {
|
|
4
|
+
const result = executeServerInfo();
|
|
5
|
+
expect(result).toContain('# m365-mcp v');
|
|
6
|
+
expect(result).toContain(`Node: ${process.version}`);
|
|
7
|
+
expect(result).toContain(`Platform: ${process.platform} ${process.arch}`);
|
|
8
|
+
});
|
|
9
|
+
it('lists all available tools', () => {
|
|
10
|
+
const result = executeServerInfo();
|
|
11
|
+
expect(result).toContain('ms_auth_status');
|
|
12
|
+
expect(result).toContain('ms_profile');
|
|
13
|
+
expect(result).toContain('ms_calendar');
|
|
14
|
+
expect(result).toContain('ms_mail');
|
|
15
|
+
expect(result).toContain('ms_chat');
|
|
16
|
+
expect(result).toContain('ms_files');
|
|
17
|
+
expect(result).toContain('ms_transcripts');
|
|
18
|
+
expect(result).toContain('ms_server_info');
|
|
19
|
+
expect(result).toContain('Tools (8)');
|
|
20
|
+
});
|
|
21
|
+
it('shows environment variable status without exposing values', () => {
|
|
22
|
+
const result = executeServerInfo();
|
|
23
|
+
expect(result).toContain('MS365_MCP_CLIENT_ID:');
|
|
24
|
+
expect(result).toContain('MS365_MCP_TENANT_ID:');
|
|
25
|
+
// Should show 'set' or 'not set', never the actual value
|
|
26
|
+
expect(result).not.toMatch(/MS365_MCP_CLIENT_ID: [a-f0-9-]{10,}/);
|
|
27
|
+
});
|
|
28
|
+
it('reports env var as set when present', () => {
|
|
29
|
+
const original = process.env['MS365_MCP_TIMEZONE'];
|
|
30
|
+
try {
|
|
31
|
+
process.env['MS365_MCP_TIMEZONE'] = 'Europe/London';
|
|
32
|
+
const result = executeServerInfo();
|
|
33
|
+
expect(result).toContain('MS365_MCP_TIMEZONE: Europe/London');
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
if (original === undefined) {
|
|
37
|
+
delete process.env['MS365_MCP_TIMEZONE'];
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
process.env['MS365_MCP_TIMEZONE'] = original;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
it('reports default for MS365_MCP_REDIRECT_URL when not set', () => {
|
|
45
|
+
const original = process.env['MS365_MCP_REDIRECT_URL'];
|
|
46
|
+
try {
|
|
47
|
+
delete process.env['MS365_MCP_REDIRECT_URL'];
|
|
48
|
+
const result = executeServerInfo();
|
|
49
|
+
expect(result).toContain('MS365_MCP_REDIRECT_URL: default (dynamic port)');
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
if (original === undefined) {
|
|
53
|
+
delete process.env['MS365_MCP_REDIRECT_URL'];
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
process.env['MS365_MCP_REDIRECT_URL'] = original;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|