@masonator/m365-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/dist/__tests__/auth.test.d.ts +1 -0
- package/dist/__tests__/auth.test.js +598 -0
- package/dist/__tests__/graph.test.d.ts +1 -0
- package/dist/__tests__/graph.test.js +161 -0
- package/dist/__tests__/tools/auth-status.test.d.ts +1 -0
- package/dist/__tests__/tools/auth-status.test.js +179 -0
- package/dist/__tests__/tools/calendar.test.d.ts +1 -0
- package/dist/__tests__/tools/calendar.test.js +154 -0
- package/dist/__tests__/tools/chat.test.d.ts +1 -0
- package/dist/__tests__/tools/chat.test.js +162 -0
- package/dist/__tests__/tools/files.test.d.ts +1 -0
- package/dist/__tests__/tools/files.test.js +143 -0
- package/dist/__tests__/tools/mail.test.d.ts +1 -0
- package/dist/__tests__/tools/mail.test.js +169 -0
- package/dist/__tests__/tools/profile.test.d.ts +1 -0
- package/dist/__tests__/tools/profile.test.js +54 -0
- package/dist/__tests__/tools/transcripts.test.d.ts +1 -0
- package/dist/__tests__/tools/transcripts.test.js +468 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +90 -0
- package/dist/lib/auth.d.ts +75 -0
- package/dist/lib/auth.js +333 -0
- package/dist/lib/graph.d.ts +20 -0
- package/dist/lib/graph.js +53 -0
- package/dist/lib/tools/auth-status.d.ts +15 -0
- package/dist/lib/tools/auth-status.js +90 -0
- package/dist/lib/tools/calendar.d.ts +30 -0
- package/dist/lib/tools/calendar.js +117 -0
- package/dist/lib/tools/chat.d.ts +25 -0
- package/dist/lib/tools/chat.js +79 -0
- package/dist/lib/tools/files.d.ts +34 -0
- package/dist/lib/tools/files.js +71 -0
- package/dist/lib/tools/mail.d.ts +25 -0
- package/dist/lib/tools/mail.js +58 -0
- package/dist/lib/tools/profile.d.ts +13 -0
- package/dist/lib/tools/profile.js +27 -0
- package/dist/lib/tools/transcripts.d.ts +51 -0
- package/dist/lib/tools/transcripts.js +244 -0
- package/dist/types/tokens.d.ts +11 -0
- package/dist/types/tokens.js +1 -0
- package/package.json +78 -0
package/dist/lib/auth.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { createServer } from 'node:net';
|
|
5
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
6
|
+
import { URL } from 'node:url';
|
|
7
|
+
import { randomBytes } from 'node:crypto';
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
const TOKEN_FILENAME = 'tokens.json';
|
|
10
|
+
const EXPIRY_BUFFER_MS = 120000; // 2 minutes
|
|
11
|
+
const AUTH_TIMEOUT_MS = 300000; // 5 minutes
|
|
12
|
+
function escapeHtml(text) {
|
|
13
|
+
return text
|
|
14
|
+
.replace(/&/g, '&')
|
|
15
|
+
.replace(/</g, '<')
|
|
16
|
+
.replace(/>/g, '>')
|
|
17
|
+
.replace(/"/g, '"')
|
|
18
|
+
.replace(/'/g, ''');
|
|
19
|
+
}
|
|
20
|
+
export const SCOPES = [
|
|
21
|
+
'openid',
|
|
22
|
+
'profile',
|
|
23
|
+
'email',
|
|
24
|
+
'offline_access',
|
|
25
|
+
'User.Read',
|
|
26
|
+
'Calendars.Read',
|
|
27
|
+
'Mail.Read',
|
|
28
|
+
'Chat.Read',
|
|
29
|
+
'Files.Read',
|
|
30
|
+
'OnlineMeetingTranscript.Read.All',
|
|
31
|
+
'Sites.Read.All',
|
|
32
|
+
];
|
|
33
|
+
/**
|
|
34
|
+
* Returns the config directory for m365-mcp.
|
|
35
|
+
* Respects XDG_CONFIG_HOME, falls back to ~/.config/m365-mcp.
|
|
36
|
+
* Creates the directory (recursively) if it doesn't exist.
|
|
37
|
+
*/
|
|
38
|
+
export function getConfigDir() {
|
|
39
|
+
const base = process.env['XDG_CONFIG_HOME'] || join(homedir(), '.config');
|
|
40
|
+
const configDir = join(base, 'm365-mcp');
|
|
41
|
+
mkdirSync(configDir, { recursive: true });
|
|
42
|
+
return configDir;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Loads tokens from tokens.json in the given config directory.
|
|
46
|
+
* Returns null if the file doesn't exist or contains invalid JSON.
|
|
47
|
+
*/
|
|
48
|
+
export function loadTokens(configDir) {
|
|
49
|
+
const dir = configDir ?? getConfigDir();
|
|
50
|
+
const filePath = join(dir, TOKEN_FILENAME);
|
|
51
|
+
try {
|
|
52
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
53
|
+
return JSON.parse(raw);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Saves tokens to tokens.json in the given config directory.
|
|
61
|
+
* Sets file permissions to 0o600 (user read/write only).
|
|
62
|
+
*/
|
|
63
|
+
export function saveTokens(tokens, configDir) {
|
|
64
|
+
const dir = configDir ?? getConfigDir();
|
|
65
|
+
const filePath = join(dir, TOKEN_FILENAME);
|
|
66
|
+
writeFileSync(filePath, JSON.stringify(tokens, null, 2), { mode: 0o600, encoding: 'utf-8' });
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Deletes tokens.json from the given config directory.
|
|
70
|
+
* Does nothing if the file doesn't exist.
|
|
71
|
+
*/
|
|
72
|
+
export function deleteTokens(configDir) {
|
|
73
|
+
const dir = configDir ?? getConfigDir();
|
|
74
|
+
const filePath = join(dir, TOKEN_FILENAME);
|
|
75
|
+
try {
|
|
76
|
+
unlinkSync(filePath);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// File doesn't exist — nothing to do
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Returns true if the token expires within 2 minutes (120 000 ms safety buffer).
|
|
84
|
+
*/
|
|
85
|
+
export function isTokenExpired(tokens) {
|
|
86
|
+
const expiresAt = new Date(tokens.expires_at).getTime();
|
|
87
|
+
return expiresAt < Date.now() + EXPIRY_BUFFER_MS;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Loads auth configuration from environment variables.
|
|
91
|
+
* Throws with a clear message if any required variable is missing.
|
|
92
|
+
*/
|
|
93
|
+
export function loadAuthConfig() {
|
|
94
|
+
const clientId = process.env['MS365_MCP_CLIENT_ID'];
|
|
95
|
+
const clientSecret = process.env['MS365_MCP_CLIENT_SECRET'];
|
|
96
|
+
const tenantId = process.env['MS365_MCP_TENANT_ID'];
|
|
97
|
+
const missing = [];
|
|
98
|
+
if (!clientId)
|
|
99
|
+
missing.push('MS365_MCP_CLIENT_ID');
|
|
100
|
+
if (!clientSecret)
|
|
101
|
+
missing.push('MS365_MCP_CLIENT_SECRET');
|
|
102
|
+
if (!tenantId)
|
|
103
|
+
missing.push('MS365_MCP_TENANT_ID');
|
|
104
|
+
if (missing.length > 0) {
|
|
105
|
+
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
clientId: clientId,
|
|
109
|
+
clientSecret: clientSecret,
|
|
110
|
+
tenantId: tenantId,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Finds an available port by binding to port 0 and reading the assigned port.
|
|
115
|
+
* @internal Exported for testing only.
|
|
116
|
+
*/
|
|
117
|
+
export function findAvailablePort() {
|
|
118
|
+
return new Promise((resolve, reject) => {
|
|
119
|
+
const srv = createServer();
|
|
120
|
+
srv.listen(0, () => {
|
|
121
|
+
const addr = srv.address();
|
|
122
|
+
if (addr && typeof addr === 'object') {
|
|
123
|
+
const port = addr.port;
|
|
124
|
+
srv.close(() => resolve(port));
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
/* istanbul ignore next -- defensive branch never reached with port 0 */
|
|
128
|
+
srv.close(() => reject(new Error('Could not determine port')));
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
srv.on('error', reject);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Opens a URL in the default browser.
|
|
136
|
+
* Falls back to printing the URL to stderr if the browser cannot be opened.
|
|
137
|
+
* @internal Exported for testing only.
|
|
138
|
+
*/
|
|
139
|
+
export function openBrowser(url) {
|
|
140
|
+
/* istanbul ignore next -- platform-specific browser launch */
|
|
141
|
+
const platform = process.platform;
|
|
142
|
+
try {
|
|
143
|
+
/* istanbul ignore next */
|
|
144
|
+
if (platform === 'darwin') {
|
|
145
|
+
execFile('open', [url]);
|
|
146
|
+
}
|
|
147
|
+
else if (platform === 'win32') {
|
|
148
|
+
execFile('cmd', ['/c', 'start', '', url]);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
execFile('xdg-open', [url]);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
process.stderr.write(`Could not open browser. Please visit:\n${url}\n`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Exchanges an authorization code for tokens via Azure AD token endpoint.
|
|
160
|
+
* @internal Exported for testing only.
|
|
161
|
+
*/
|
|
162
|
+
export async function exchangeCodeForTokens(config, code, redirectUri) {
|
|
163
|
+
const tokenUrl = `https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token`;
|
|
164
|
+
const body = new URLSearchParams({
|
|
165
|
+
grant_type: 'authorization_code',
|
|
166
|
+
client_id: config.clientId,
|
|
167
|
+
client_secret: config.clientSecret,
|
|
168
|
+
code,
|
|
169
|
+
redirect_uri: redirectUri,
|
|
170
|
+
});
|
|
171
|
+
const response = await fetch(tokenUrl, {
|
|
172
|
+
method: 'POST',
|
|
173
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
174
|
+
body: body.toString(),
|
|
175
|
+
});
|
|
176
|
+
if (!response.ok) {
|
|
177
|
+
const errorText = await response.text();
|
|
178
|
+
throw new Error(`Token exchange failed (${response.status}): ${errorText}`);
|
|
179
|
+
}
|
|
180
|
+
const data = (await response.json());
|
|
181
|
+
return {
|
|
182
|
+
access_token: data.access_token,
|
|
183
|
+
refresh_token: data.refresh_token,
|
|
184
|
+
expires_at: new Date(Date.now() + data.expires_in * 1000).toISOString(),
|
|
185
|
+
scopes: data.scope,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Starts an HTTP server on the given port and waits for the OAuth callback.
|
|
190
|
+
* Returns the authorization code from the callback query string.
|
|
191
|
+
* @internal Exported for testing only.
|
|
192
|
+
*/
|
|
193
|
+
export function waitForAuthCallback(port, expectedState, timeoutMs = AUTH_TIMEOUT_MS) {
|
|
194
|
+
let httpServer;
|
|
195
|
+
const promise = new Promise((resolve, reject) => {
|
|
196
|
+
const closeServer = () => {
|
|
197
|
+
httpServer.close();
|
|
198
|
+
httpServer.closeAllConnections();
|
|
199
|
+
};
|
|
200
|
+
const timeout = setTimeout(() => {
|
|
201
|
+
closeServer();
|
|
202
|
+
reject(new Error('Authentication timed out after 5 minutes. Please try again.'));
|
|
203
|
+
}, timeoutMs);
|
|
204
|
+
httpServer = createHttpServer((req, res) => {
|
|
205
|
+
if (!req.url?.startsWith('/callback')) {
|
|
206
|
+
res.writeHead(404);
|
|
207
|
+
res.end('Not found');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const url = new URL(req.url, `http://localhost:${port}`);
|
|
211
|
+
const callbackState = url.searchParams.get('state');
|
|
212
|
+
const callbackCode = url.searchParams.get('code');
|
|
213
|
+
const error = url.searchParams.get('error');
|
|
214
|
+
if (error) {
|
|
215
|
+
const errorDesc = url.searchParams.get('error_description') || error;
|
|
216
|
+
const safeDesc = escapeHtml(errorDesc);
|
|
217
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
218
|
+
res.end(`<!DOCTYPE html><html><body style="font-family:sans-serif;text-align:center;padding:40px"><h1>Sign-in failed</h1><p>${safeDesc}</p></body></html>`);
|
|
219
|
+
clearTimeout(timeout);
|
|
220
|
+
closeServer();
|
|
221
|
+
reject(new Error(`Authentication failed: ${errorDesc}`));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (callbackState !== expectedState) {
|
|
225
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
226
|
+
res.end('<!DOCTYPE html><html><body style="font-family:sans-serif;text-align:center;padding:40px"><h1>Error</h1><p>State mismatch — possible CSRF attack.</p></body></html>');
|
|
227
|
+
clearTimeout(timeout);
|
|
228
|
+
closeServer();
|
|
229
|
+
reject(new Error('State mismatch in OAuth callback'));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (!callbackCode) {
|
|
233
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
234
|
+
res.end('<!DOCTYPE html><html><body style="font-family:sans-serif;text-align:center;padding:40px"><h1>Error</h1><p>No authorization code received.</p></body></html>');
|
|
235
|
+
clearTimeout(timeout);
|
|
236
|
+
closeServer();
|
|
237
|
+
reject(new Error('No authorization code in callback'));
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
241
|
+
res.end('<!DOCTYPE html><html><body style="font-family:sans-serif;text-align:center;padding:40px"><h1>Signed in!</h1><p>You can close this tab.</p></body></html>');
|
|
242
|
+
clearTimeout(timeout);
|
|
243
|
+
closeServer();
|
|
244
|
+
resolve(callbackCode);
|
|
245
|
+
});
|
|
246
|
+
httpServer.listen(port);
|
|
247
|
+
});
|
|
248
|
+
return { promise, server: httpServer };
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Browser-popup + localhost-callback OAuth2 flow.
|
|
252
|
+
* Opens the browser for Microsoft 365 sign-in, listens for the callback,
|
|
253
|
+
* exchanges the authorization code for tokens, saves them, and returns the TokenData.
|
|
254
|
+
*/
|
|
255
|
+
export async function startAuthFlow(config) {
|
|
256
|
+
const port = await findAvailablePort();
|
|
257
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
258
|
+
const state = randomBytes(16).toString('hex');
|
|
259
|
+
const authUrl = new URL(`https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/authorize`);
|
|
260
|
+
authUrl.searchParams.set('client_id', config.clientId);
|
|
261
|
+
authUrl.searchParams.set('response_type', 'code');
|
|
262
|
+
authUrl.searchParams.set('redirect_uri', redirectUri);
|
|
263
|
+
authUrl.searchParams.set('scope', SCOPES.join(' '));
|
|
264
|
+
authUrl.searchParams.set('state', state);
|
|
265
|
+
process.stderr.write('Opening browser for Microsoft 365 sign-in...\n');
|
|
266
|
+
openBrowser(authUrl.toString());
|
|
267
|
+
const { promise } = waitForAuthCallback(port, state);
|
|
268
|
+
const code = await promise;
|
|
269
|
+
const tokenData = await exchangeCodeForTokens(config, code, redirectUri);
|
|
270
|
+
saveTokens(tokenData);
|
|
271
|
+
return tokenData;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Refreshes an access token using a refresh token.
|
|
275
|
+
* On success, saves and returns the new TokenData.
|
|
276
|
+
* On failure, deletes stored tokens and returns null.
|
|
277
|
+
*/
|
|
278
|
+
export async function refreshAccessToken(config, refreshToken) {
|
|
279
|
+
const tokenUrl = `https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token`;
|
|
280
|
+
const body = new URLSearchParams({
|
|
281
|
+
grant_type: 'refresh_token',
|
|
282
|
+
client_id: config.clientId,
|
|
283
|
+
client_secret: config.clientSecret,
|
|
284
|
+
refresh_token: refreshToken,
|
|
285
|
+
});
|
|
286
|
+
try {
|
|
287
|
+
const response = await fetch(tokenUrl, {
|
|
288
|
+
method: 'POST',
|
|
289
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
290
|
+
body: body.toString(),
|
|
291
|
+
});
|
|
292
|
+
if (!response.ok) {
|
|
293
|
+
const errorText = await response.text();
|
|
294
|
+
process.stderr.write(`Token refresh failed (${response.status}): ${errorText}\n`);
|
|
295
|
+
deleteTokens();
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
const data = (await response.json());
|
|
299
|
+
const tokenData = {
|
|
300
|
+
access_token: data.access_token,
|
|
301
|
+
refresh_token: data.refresh_token,
|
|
302
|
+
expires_at: new Date(Date.now() + data.expires_in * 1000).toISOString(),
|
|
303
|
+
scopes: data.scope,
|
|
304
|
+
};
|
|
305
|
+
saveTokens(tokenData);
|
|
306
|
+
return tokenData;
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
process.stderr.write(`Token refresh error: ${err instanceof Error ? err.message : err}\n`);
|
|
310
|
+
deleteTokens();
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Main entry point — returns a valid access token.
|
|
316
|
+
* Loads cached tokens, refreshes if expired, or starts a new auth flow if needed.
|
|
317
|
+
*/
|
|
318
|
+
export async function getAccessToken(config) {
|
|
319
|
+
const tokens = loadTokens();
|
|
320
|
+
if (!tokens) {
|
|
321
|
+
const newTokens = await startAuthFlow(config);
|
|
322
|
+
return newTokens.access_token;
|
|
323
|
+
}
|
|
324
|
+
if (!isTokenExpired(tokens)) {
|
|
325
|
+
return tokens.access_token;
|
|
326
|
+
}
|
|
327
|
+
const refreshed = await refreshAccessToken(config, tokens.refresh_token);
|
|
328
|
+
if (refreshed) {
|
|
329
|
+
return refreshed.access_token;
|
|
330
|
+
}
|
|
331
|
+
const newTokens = await startAuthFlow(config);
|
|
332
|
+
return newTokens.access_token;
|
|
333
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface GraphError {
|
|
2
|
+
status: number;
|
|
3
|
+
message: string;
|
|
4
|
+
}
|
|
5
|
+
export type GraphResult<T> = {
|
|
6
|
+
ok: true;
|
|
7
|
+
data: T;
|
|
8
|
+
} | {
|
|
9
|
+
ok: false;
|
|
10
|
+
error: GraphError;
|
|
11
|
+
};
|
|
12
|
+
export interface GraphFetchOptions {
|
|
13
|
+
beta?: boolean;
|
|
14
|
+
timezone?: boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Thin fetch wrapper for Microsoft Graph API calls.
|
|
18
|
+
* Translates HTTP errors into typed GraphError results.
|
|
19
|
+
*/
|
|
20
|
+
export declare function graphFetch<T>(path: string, token: string, options?: GraphFetchOptions): Promise<GraphResult<T>>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin fetch wrapper for Microsoft Graph API calls.
|
|
3
|
+
* Translates HTTP errors into typed GraphError results.
|
|
4
|
+
*/
|
|
5
|
+
export async function graphFetch(path, token, options) {
|
|
6
|
+
const base = options?.beta
|
|
7
|
+
? 'https://graph.microsoft.com/beta'
|
|
8
|
+
: 'https://graph.microsoft.com/v1.0';
|
|
9
|
+
const headers = {
|
|
10
|
+
Authorization: `Bearer ${token}`,
|
|
11
|
+
'Content-Type': 'application/json',
|
|
12
|
+
};
|
|
13
|
+
if (options?.timezone !== false) {
|
|
14
|
+
const tz = process.env.MS365_MCP_TIMEZONE || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
15
|
+
headers['Prefer'] = `outlook.timezone="${tz}"`;
|
|
16
|
+
}
|
|
17
|
+
let response;
|
|
18
|
+
try {
|
|
19
|
+
response = await fetch(`${base}${path}`, { headers });
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
error: {
|
|
25
|
+
status: 0,
|
|
26
|
+
message: `Network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (response.ok) {
|
|
31
|
+
const data = (await response.json());
|
|
32
|
+
return { ok: true, data };
|
|
33
|
+
}
|
|
34
|
+
const status = response.status;
|
|
35
|
+
let message;
|
|
36
|
+
switch (status) {
|
|
37
|
+
case 401:
|
|
38
|
+
message = 'Graph token expired. Use ms_auth_status to reconnect.';
|
|
39
|
+
break;
|
|
40
|
+
case 403:
|
|
41
|
+
message = 'Insufficient permissions. Check granted scopes with ms_auth_status.';
|
|
42
|
+
break;
|
|
43
|
+
case 404:
|
|
44
|
+
message = 'Resource not found. Your account may not have an Exchange Online license.';
|
|
45
|
+
break;
|
|
46
|
+
default: {
|
|
47
|
+
const text = await response.text();
|
|
48
|
+
message = `Graph API error (${status}): ${text}`;
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { ok: false, error: { status, message } };
|
|
53
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AuthConfig } from '../../types/tokens.js';
|
|
2
|
+
export declare const authStatusToolDefinition: {
|
|
3
|
+
name: string;
|
|
4
|
+
description: string;
|
|
5
|
+
inputSchema: {
|
|
6
|
+
type: "object";
|
|
7
|
+
properties: {};
|
|
8
|
+
};
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Check Microsoft 365 connection status.
|
|
12
|
+
* Handles the full lifecycle: no tokens, expired tokens, valid tokens.
|
|
13
|
+
* Triggers auth flow if not connected.
|
|
14
|
+
*/
|
|
15
|
+
export declare function executeAuthStatus(config: AuthConfig): Promise<string>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { loadTokens, isTokenExpired, startAuthFlow, SCOPES } from '../auth.js';
|
|
2
|
+
import { refreshAccessToken } from '../auth.js';
|
|
3
|
+
import { graphFetch } from '../graph.js';
|
|
4
|
+
export const authStatusToolDefinition = {
|
|
5
|
+
name: 'ms_auth_status',
|
|
6
|
+
description: 'Check Microsoft 365 connection status. If not connected, opens browser to sign in.',
|
|
7
|
+
inputSchema: {
|
|
8
|
+
type: 'object',
|
|
9
|
+
properties: {},
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Fetches a short profile summary for the status display.
|
|
14
|
+
* Returns null if the fetch fails.
|
|
15
|
+
*/
|
|
16
|
+
async function fetchProfileSummary(token) {
|
|
17
|
+
const result = await graphFetch('/me', token);
|
|
18
|
+
if (!result.ok) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
displayName: result.data.displayName || 'Unknown',
|
|
23
|
+
email: result.data.mail || result.data.userPrincipalName || 'Unknown',
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Formats a "just signed in" status message.
|
|
28
|
+
*/
|
|
29
|
+
function formatJustConnected(profile) {
|
|
30
|
+
const lines = ['Status: Connected \u2713 (just signed in)'];
|
|
31
|
+
if (profile) {
|
|
32
|
+
lines.push(`User: ${profile.displayName} (${profile.email})`);
|
|
33
|
+
}
|
|
34
|
+
return lines.join('\n');
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Formats a full connected status message with token details.
|
|
38
|
+
*/
|
|
39
|
+
function formatConnectedStatus(tokens, profile) {
|
|
40
|
+
const lines = ['Status: Connected \u2713'];
|
|
41
|
+
if (profile) {
|
|
42
|
+
lines.push(`User: ${profile.displayName} (${profile.email})`);
|
|
43
|
+
}
|
|
44
|
+
lines.push(`Token expires: ${tokens.expires_at}`);
|
|
45
|
+
lines.push(`Scopes: ${tokens.scopes || SCOPES.join(' ')}`);
|
|
46
|
+
return lines.join('\n');
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Formats an error status message.
|
|
50
|
+
*/
|
|
51
|
+
function formatError(message) {
|
|
52
|
+
return [
|
|
53
|
+
'Status: Not connected',
|
|
54
|
+
`Error: ${message}`,
|
|
55
|
+
'Action: Run ms_auth_status again to sign in.',
|
|
56
|
+
].join('\n');
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Check Microsoft 365 connection status.
|
|
60
|
+
* Handles the full lifecycle: no tokens, expired tokens, valid tokens.
|
|
61
|
+
* Triggers auth flow if not connected.
|
|
62
|
+
*/
|
|
63
|
+
export async function executeAuthStatus(config) {
|
|
64
|
+
try {
|
|
65
|
+
const tokens = loadTokens();
|
|
66
|
+
// No tokens — start auth flow
|
|
67
|
+
if (!tokens) {
|
|
68
|
+
process.stderr.write('Not connected. Starting auth flow...\n');
|
|
69
|
+
const newTokens = await startAuthFlow(config);
|
|
70
|
+
const profile = await fetchProfileSummary(newTokens.access_token);
|
|
71
|
+
return formatJustConnected(profile);
|
|
72
|
+
}
|
|
73
|
+
// Tokens exist but expired — try refresh
|
|
74
|
+
if (isTokenExpired(tokens)) {
|
|
75
|
+
const refreshed = await refreshAccessToken(config, tokens.refresh_token);
|
|
76
|
+
if (!refreshed) {
|
|
77
|
+
return formatError('Token expired and refresh failed. Please sign in again.');
|
|
78
|
+
}
|
|
79
|
+
const profile = await fetchProfileSummary(refreshed.access_token);
|
|
80
|
+
return formatConnectedStatus(refreshed, profile);
|
|
81
|
+
}
|
|
82
|
+
// Tokens valid — show status
|
|
83
|
+
const profile = await fetchProfileSummary(tokens.access_token);
|
|
84
|
+
return formatConnectedStatus(tokens, profile);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
88
|
+
return formatError(message);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export declare const calendarToolDefinition: {
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
inputSchema: {
|
|
5
|
+
type: "object";
|
|
6
|
+
properties: {
|
|
7
|
+
date: {
|
|
8
|
+
type: string;
|
|
9
|
+
description: string;
|
|
10
|
+
};
|
|
11
|
+
start: {
|
|
12
|
+
type: string;
|
|
13
|
+
description: string;
|
|
14
|
+
};
|
|
15
|
+
end: {
|
|
16
|
+
type: string;
|
|
17
|
+
description: string;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Fetches calendar events for the specified date range and returns
|
|
24
|
+
* a human-readable summary.
|
|
25
|
+
*/
|
|
26
|
+
export declare function executeCalendar(token: string, args: {
|
|
27
|
+
date?: string;
|
|
28
|
+
start?: string;
|
|
29
|
+
end?: string;
|
|
30
|
+
}): Promise<string>;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { graphFetch } from '../graph.js';
|
|
2
|
+
export const calendarToolDefinition = {
|
|
3
|
+
name: 'ms_calendar',
|
|
4
|
+
description: "Fetch the user's Microsoft 365 calendar events. Defaults to today if no date params given.",
|
|
5
|
+
inputSchema: {
|
|
6
|
+
type: 'object',
|
|
7
|
+
properties: {
|
|
8
|
+
date: { type: 'string', description: 'Fetch events for a specific date (YYYY-MM-DD)' },
|
|
9
|
+
start: { type: 'string', description: 'Start of date range (ISO 8601)' },
|
|
10
|
+
end: { type: 'string', description: 'End of date range (ISO 8601)' },
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Computes the start and end ISO strings for a given YYYY-MM-DD date.
|
|
16
|
+
*/
|
|
17
|
+
function dateRangeForDay(dateStr) {
|
|
18
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr))
|
|
19
|
+
return null;
|
|
20
|
+
const date = new Date(`${dateStr}T00:00:00.000Z`);
|
|
21
|
+
if (isNaN(date.getTime()))
|
|
22
|
+
return null;
|
|
23
|
+
const next = new Date(date);
|
|
24
|
+
next.setUTCDate(next.getUTCDate() + 1);
|
|
25
|
+
return { start: date.toISOString(), end: next.toISOString() };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Returns today's date range (local midnight to next midnight) in ISO format.
|
|
29
|
+
*/
|
|
30
|
+
function todayRange() {
|
|
31
|
+
const now = new Date();
|
|
32
|
+
const year = now.getFullYear();
|
|
33
|
+
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
34
|
+
const day = String(now.getDate()).padStart(2, '0');
|
|
35
|
+
return dateRangeForDay(`${year}-${month}-${day}`);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Formats a calendar event into readable multi-line text.
|
|
39
|
+
*/
|
|
40
|
+
function formatEvent(event) {
|
|
41
|
+
const lines = [];
|
|
42
|
+
lines.push(`## ${event.subject || 'Untitled'}`);
|
|
43
|
+
if (event.isAllDay) {
|
|
44
|
+
lines.push('Time: All day');
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
const startTime = event.start?.dateTime || 'N/A';
|
|
48
|
+
const endTime = event.end?.dateTime || 'N/A';
|
|
49
|
+
lines.push(`Time: ${startTime} - ${endTime}`);
|
|
50
|
+
}
|
|
51
|
+
if (event.location?.displayName) {
|
|
52
|
+
lines.push(`Location: ${event.location.displayName}`);
|
|
53
|
+
}
|
|
54
|
+
if (event.organizer?.emailAddress?.name) {
|
|
55
|
+
lines.push(`Organizer: ${event.organizer.emailAddress.name}`);
|
|
56
|
+
}
|
|
57
|
+
if (event.attendees && event.attendees.length > 0) {
|
|
58
|
+
const names = event.attendees
|
|
59
|
+
.map((a) => a.emailAddress?.name)
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.join(', ');
|
|
62
|
+
if (names) {
|
|
63
|
+
lines.push(`Attendees: ${names}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (event.bodyPreview) {
|
|
67
|
+
lines.push(event.bodyPreview);
|
|
68
|
+
}
|
|
69
|
+
return lines.join('\n');
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Fetches calendar events for the specified date range and returns
|
|
73
|
+
* a human-readable summary.
|
|
74
|
+
*/
|
|
75
|
+
export async function executeCalendar(token, args) {
|
|
76
|
+
let start;
|
|
77
|
+
let end;
|
|
78
|
+
if (args.date) {
|
|
79
|
+
const range = dateRangeForDay(args.date);
|
|
80
|
+
if (!range)
|
|
81
|
+
return 'Error: Invalid date format. Expected YYYY-MM-DD.';
|
|
82
|
+
start = range.start;
|
|
83
|
+
end = range.end;
|
|
84
|
+
}
|
|
85
|
+
else if (args.start && args.end) {
|
|
86
|
+
start = args.start;
|
|
87
|
+
end = args.end;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
const range = todayRange();
|
|
91
|
+
start = range.start;
|
|
92
|
+
end = range.end;
|
|
93
|
+
}
|
|
94
|
+
const select = [
|
|
95
|
+
'subject',
|
|
96
|
+
'start',
|
|
97
|
+
'end',
|
|
98
|
+
'location',
|
|
99
|
+
'attendees',
|
|
100
|
+
'isAllDay',
|
|
101
|
+
'bodyPreview',
|
|
102
|
+
'organizer',
|
|
103
|
+
'onlineMeeting',
|
|
104
|
+
'webLink',
|
|
105
|
+
].join(',');
|
|
106
|
+
const path = `/me/calendarView?startDateTime=${start}&endDateTime=${end}` +
|
|
107
|
+
`&$orderby=start/dateTime&$top=50&$select=${select}`;
|
|
108
|
+
const result = await graphFetch(path, token, { timezone: true });
|
|
109
|
+
if (!result.ok) {
|
|
110
|
+
return `Error: ${result.error.message}`;
|
|
111
|
+
}
|
|
112
|
+
const events = result.data.value;
|
|
113
|
+
if (!events || events.length === 0) {
|
|
114
|
+
return 'No calendar events found for the specified date range.';
|
|
115
|
+
}
|
|
116
|
+
return events.map(formatEvent).join('\n\n');
|
|
117
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export declare const chatToolDefinition: {
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
inputSchema: {
|
|
5
|
+
type: "object";
|
|
6
|
+
properties: {
|
|
7
|
+
chat_id: {
|
|
8
|
+
type: string;
|
|
9
|
+
description: string;
|
|
10
|
+
};
|
|
11
|
+
count: {
|
|
12
|
+
type: string;
|
|
13
|
+
description: string;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Fetches Teams chats or messages from a specific chat thread and returns
|
|
20
|
+
* a human-readable summary.
|
|
21
|
+
*/
|
|
22
|
+
export declare function executeChat(token: string, args: {
|
|
23
|
+
chat_id?: string;
|
|
24
|
+
count?: number;
|
|
25
|
+
}): Promise<string>;
|