@bugroger/lokka 0.3.5
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/README.md +252 -0
- package/build/auth.js +501 -0
- package/build/constants.js +11 -0
- package/build/logger.js +28 -0
- package/build/main.js +587 -0
- package/build/mcp-server.log +148 -0
- package/package.json +54 -0
package/build/main.js
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { Client, PageIterator } from "@microsoft/microsoft-graph-client";
|
|
6
|
+
import fetch from 'isomorphic-fetch'; // Required polyfill for Graph client
|
|
7
|
+
import { logger } from "./logger.js";
|
|
8
|
+
import { AuthManager, AuthMode } from "./auth.js";
|
|
9
|
+
import { LokkaClientId, LokkaDefaultTenantId, LokkaDefaultRedirectUri, LokkaTokenPath, getDefaultGraphApiVersion } from "./constants.js";
|
|
10
|
+
import fs from "fs";
|
|
11
|
+
// Set up global fetch for the Microsoft Graph client
|
|
12
|
+
global.fetch = fetch;
|
|
13
|
+
// Create server instance
|
|
14
|
+
const server = new McpServer({
|
|
15
|
+
name: "Lokka-Microsoft",
|
|
16
|
+
version: "0.2.0", // Updated version for token-based auth support
|
|
17
|
+
});
|
|
18
|
+
logger.info("Starting Lokka Multi-Microsoft API MCP Server (v0.2.0 - Token-Based Auth Support)");
|
|
19
|
+
// Initialize authentication and clients
|
|
20
|
+
let authManager = null;
|
|
21
|
+
let graphClient = null;
|
|
22
|
+
// Check USE_GRAPH_BETA environment variable
|
|
23
|
+
const useGraphBeta = process.env.USE_GRAPH_BETA !== 'false'; // Default to true unless explicitly set to 'false'
|
|
24
|
+
const defaultGraphApiVersion = getDefaultGraphApiVersion();
|
|
25
|
+
logger.info(`Graph API default version: ${defaultGraphApiVersion} (USE_GRAPH_BETA=${process.env.USE_GRAPH_BETA || 'undefined'})`);
|
|
26
|
+
server.tool("Lokka-Microsoft", "A versatile tool to interact with Microsoft APIs including Microsoft Graph (Entra) and Azure Resource Management. IMPORTANT: For Graph API GET requests using advanced query parameters ($filter, $count, $search, $orderby), you are ADVISED to set 'consistencyLevel: \"eventual\"'.", {
|
|
27
|
+
apiType: z.enum(["graph", "azure"]).describe("Type of Microsoft API to query. Options: 'graph' for Microsoft Graph (Entra) or 'azure' for Azure Resource Management."),
|
|
28
|
+
path: z.string().describe("The Azure or Graph API URL path to call (e.g. '/users', '/groups', '/subscriptions')"),
|
|
29
|
+
method: z.enum(["get", "post", "put", "patch", "delete"]).describe("HTTP method to use"),
|
|
30
|
+
apiVersion: z.string().optional().describe("Azure Resource Management API version (required for apiType Azure)"),
|
|
31
|
+
subscriptionId: z.string().optional().describe("Azure Subscription ID (for Azure Resource Management)."),
|
|
32
|
+
queryParams: z.record(z.string()).optional().describe("Query parameters for the request"),
|
|
33
|
+
body: z.record(z.string(), z.any()).optional().describe("The request body (for POST, PUT, PATCH)"),
|
|
34
|
+
graphApiVersion: z.enum(["v1.0", "beta"]).optional().default(defaultGraphApiVersion).describe(`Microsoft Graph API version to use (default: ${defaultGraphApiVersion})`),
|
|
35
|
+
fetchAll: z.boolean().optional().default(false).describe("Set to true to automatically fetch all pages for list results (e.g., users, groups). Default is false."),
|
|
36
|
+
consistencyLevel: z.string().optional().describe("Graph API ConsistencyLevel header. ADVISED to be set to 'eventual' for Graph GET requests using advanced query parameters ($filter, $count, $search, $orderby)."),
|
|
37
|
+
}, async ({ apiType, path, method, apiVersion, subscriptionId, queryParams, body, graphApiVersion, fetchAll, consistencyLevel }) => {
|
|
38
|
+
// Override graphApiVersion if USE_GRAPH_BETA is explicitly set to false
|
|
39
|
+
const effectiveGraphApiVersion = !useGraphBeta ? "v1.0" : graphApiVersion;
|
|
40
|
+
logger.info(`Executing Lokka-Microsoft tool with params: apiType=${apiType}, path=${path}, method=${method}, graphApiVersion=${effectiveGraphApiVersion}, fetchAll=${fetchAll}, consistencyLevel=${consistencyLevel}`);
|
|
41
|
+
let determinedUrl;
|
|
42
|
+
try {
|
|
43
|
+
let responseData;
|
|
44
|
+
// --- Microsoft Graph Logic ---
|
|
45
|
+
if (apiType === 'graph') {
|
|
46
|
+
if (!graphClient) {
|
|
47
|
+
throw new Error("Graph client not initialized");
|
|
48
|
+
}
|
|
49
|
+
determinedUrl = `https://graph.microsoft.com/${effectiveGraphApiVersion}`; // For error reporting
|
|
50
|
+
// Construct the request using the Graph SDK client
|
|
51
|
+
let request = graphClient.api(path).version(effectiveGraphApiVersion);
|
|
52
|
+
// Add query parameters if provided and not empty
|
|
53
|
+
if (queryParams && Object.keys(queryParams).length > 0) {
|
|
54
|
+
request = request.query(queryParams);
|
|
55
|
+
}
|
|
56
|
+
// Add ConsistencyLevel header if provided
|
|
57
|
+
if (consistencyLevel) {
|
|
58
|
+
request = request.header('ConsistencyLevel', consistencyLevel);
|
|
59
|
+
logger.info(`Added ConsistencyLevel header: ${consistencyLevel}`);
|
|
60
|
+
}
|
|
61
|
+
// Handle different methods
|
|
62
|
+
switch (method.toLowerCase()) {
|
|
63
|
+
case 'get':
|
|
64
|
+
if (fetchAll) {
|
|
65
|
+
logger.info(`Fetching all pages for Graph path: ${path}`);
|
|
66
|
+
// Fetch the first page to get context and initial data
|
|
67
|
+
const firstPageResponse = await request.get();
|
|
68
|
+
const odataContext = firstPageResponse['@odata.context']; // Capture context from first page
|
|
69
|
+
let allItems = firstPageResponse.value || []; // Initialize with first page's items
|
|
70
|
+
// Callback function to process subsequent pages
|
|
71
|
+
const callback = (item) => {
|
|
72
|
+
allItems.push(item);
|
|
73
|
+
return true; // Return true to continue iteration
|
|
74
|
+
};
|
|
75
|
+
// Create a PageIterator starting from the first response
|
|
76
|
+
const pageIterator = new PageIterator(graphClient, firstPageResponse, callback);
|
|
77
|
+
// Iterate over all remaining pages
|
|
78
|
+
await pageIterator.iterate();
|
|
79
|
+
// Construct final response with context and combined values under 'value' key
|
|
80
|
+
responseData = {
|
|
81
|
+
'@odata.context': odataContext,
|
|
82
|
+
value: allItems
|
|
83
|
+
};
|
|
84
|
+
logger.info(`Finished fetching all Graph pages. Total items: ${allItems.length}`);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
logger.info(`Fetching single page for Graph path: ${path}`);
|
|
88
|
+
responseData = await request.get();
|
|
89
|
+
}
|
|
90
|
+
break;
|
|
91
|
+
case 'post':
|
|
92
|
+
responseData = await request.post(body ?? {});
|
|
93
|
+
break;
|
|
94
|
+
case 'put':
|
|
95
|
+
responseData = await request.put(body ?? {});
|
|
96
|
+
break;
|
|
97
|
+
case 'patch':
|
|
98
|
+
responseData = await request.patch(body ?? {});
|
|
99
|
+
break;
|
|
100
|
+
case 'delete':
|
|
101
|
+
responseData = await request.delete(); // Delete often returns no body or 204
|
|
102
|
+
// Handle potential 204 No Content response
|
|
103
|
+
if (responseData === undefined || responseData === null) {
|
|
104
|
+
responseData = { status: "Success (No Content)" };
|
|
105
|
+
}
|
|
106
|
+
break;
|
|
107
|
+
default:
|
|
108
|
+
throw new Error(`Unsupported method: ${method}`);
|
|
109
|
+
}
|
|
110
|
+
} // --- Azure Resource Management Logic (using direct fetch) ---
|
|
111
|
+
else { // apiType === 'azure'
|
|
112
|
+
if (!authManager) {
|
|
113
|
+
throw new Error("Auth manager not initialized");
|
|
114
|
+
}
|
|
115
|
+
determinedUrl = "https://management.azure.com"; // For error reporting
|
|
116
|
+
// Acquire token for Azure RM
|
|
117
|
+
const azureCredential = authManager.getAzureCredential();
|
|
118
|
+
const tokenResponse = await azureCredential.getToken("https://management.azure.com/.default");
|
|
119
|
+
if (!tokenResponse || !tokenResponse.token) {
|
|
120
|
+
throw new Error("Failed to acquire Azure access token");
|
|
121
|
+
}
|
|
122
|
+
// Construct the URL (similar to previous implementation)
|
|
123
|
+
let url = determinedUrl;
|
|
124
|
+
if (subscriptionId) {
|
|
125
|
+
url += `/subscriptions/${subscriptionId}`;
|
|
126
|
+
}
|
|
127
|
+
url += path;
|
|
128
|
+
if (!apiVersion) {
|
|
129
|
+
throw new Error("API version is required for Azure Resource Management queries");
|
|
130
|
+
}
|
|
131
|
+
const urlParams = new URLSearchParams({ 'api-version': apiVersion });
|
|
132
|
+
if (queryParams) {
|
|
133
|
+
for (const [key, value] of Object.entries(queryParams)) {
|
|
134
|
+
urlParams.append(String(key), String(value));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
url += `?${urlParams.toString()}`;
|
|
138
|
+
// Prepare request options
|
|
139
|
+
const headers = {
|
|
140
|
+
'Authorization': `Bearer ${tokenResponse.token}`,
|
|
141
|
+
'Content-Type': 'application/json'
|
|
142
|
+
};
|
|
143
|
+
const requestOptions = {
|
|
144
|
+
method: method.toUpperCase(),
|
|
145
|
+
headers: headers
|
|
146
|
+
};
|
|
147
|
+
if (["POST", "PUT", "PATCH"].includes(method.toUpperCase())) {
|
|
148
|
+
requestOptions.body = body ? JSON.stringify(body) : JSON.stringify({});
|
|
149
|
+
}
|
|
150
|
+
// --- Pagination Logic for Azure RM (Manual Fetch) ---
|
|
151
|
+
if (fetchAll && method === 'get') {
|
|
152
|
+
logger.info(`Fetching all pages for Azure RM starting from: ${url}`);
|
|
153
|
+
let allValues = [];
|
|
154
|
+
let currentUrl = url;
|
|
155
|
+
while (currentUrl) {
|
|
156
|
+
logger.info(`Fetching Azure RM page: ${currentUrl}`);
|
|
157
|
+
// Re-acquire token for each page (Azure tokens might expire)
|
|
158
|
+
const azureCredential = authManager.getAzureCredential();
|
|
159
|
+
const currentPageTokenResponse = await azureCredential.getToken("https://management.azure.com/.default");
|
|
160
|
+
if (!currentPageTokenResponse || !currentPageTokenResponse.token) {
|
|
161
|
+
throw new Error("Failed to acquire Azure access token during pagination");
|
|
162
|
+
}
|
|
163
|
+
const currentPageHeaders = { ...headers, 'Authorization': `Bearer ${currentPageTokenResponse.token}` };
|
|
164
|
+
const currentPageRequestOptions = { method: 'GET', headers: currentPageHeaders };
|
|
165
|
+
const pageResponse = await fetch(currentUrl, currentPageRequestOptions);
|
|
166
|
+
const pageText = await pageResponse.text();
|
|
167
|
+
let pageData;
|
|
168
|
+
try {
|
|
169
|
+
pageData = pageText ? JSON.parse(pageText) : {};
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
logger.error(`Failed to parse JSON from Azure RM page: ${currentUrl}`, pageText);
|
|
173
|
+
pageData = { rawResponse: pageText };
|
|
174
|
+
}
|
|
175
|
+
if (!pageResponse.ok) {
|
|
176
|
+
logger.error(`API error on Azure RM page ${currentUrl}:`, pageData);
|
|
177
|
+
throw new Error(`API error (${pageResponse.status}) during Azure RM pagination on ${currentUrl}: ${JSON.stringify(pageData)}`);
|
|
178
|
+
}
|
|
179
|
+
if (pageData.value && Array.isArray(pageData.value)) {
|
|
180
|
+
allValues = allValues.concat(pageData.value);
|
|
181
|
+
}
|
|
182
|
+
else if (currentUrl === url && !pageData.nextLink) {
|
|
183
|
+
allValues.push(pageData);
|
|
184
|
+
}
|
|
185
|
+
else if (currentUrl !== url) {
|
|
186
|
+
logger.info(`[Warning] Azure RM response from ${currentUrl} did not contain a 'value' array.`);
|
|
187
|
+
}
|
|
188
|
+
currentUrl = pageData.nextLink || null; // Azure uses nextLink
|
|
189
|
+
}
|
|
190
|
+
responseData = { allValues: allValues };
|
|
191
|
+
logger.info(`Finished fetching all Azure RM pages. Total items: ${allValues.length}`);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
// Single page fetch for Azure RM
|
|
195
|
+
logger.info(`Fetching single page for Azure RM: ${url}`);
|
|
196
|
+
const apiResponse = await fetch(url, requestOptions);
|
|
197
|
+
const responseText = await apiResponse.text();
|
|
198
|
+
try {
|
|
199
|
+
responseData = responseText ? JSON.parse(responseText) : {};
|
|
200
|
+
}
|
|
201
|
+
catch (e) {
|
|
202
|
+
logger.error(`Failed to parse JSON from single Azure RM page: ${url}`, responseText);
|
|
203
|
+
responseData = { rawResponse: responseText };
|
|
204
|
+
}
|
|
205
|
+
if (!apiResponse.ok) {
|
|
206
|
+
logger.error(`API error for Azure RM ${method} ${path}:`, responseData);
|
|
207
|
+
throw new Error(`API error (${apiResponse.status}) for Azure RM: ${JSON.stringify(responseData)}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// --- Format and Return Result ---
|
|
212
|
+
// For all requests, format as text
|
|
213
|
+
let resultText = `Result for ${apiType} API (${apiType === 'graph' ? effectiveGraphApiVersion : apiVersion}) - ${method} ${path}:\n\n`;
|
|
214
|
+
resultText += JSON.stringify(responseData, null, 2); // responseData already contains the correct structure for fetchAll Graph case
|
|
215
|
+
// Add pagination note if applicable (only for single page GET)
|
|
216
|
+
if (!fetchAll && method === 'get') {
|
|
217
|
+
const nextLinkKey = apiType === 'graph' ? '@odata.nextLink' : 'nextLink';
|
|
218
|
+
if (responseData && responseData[nextLinkKey]) { // Added check for responseData existence
|
|
219
|
+
resultText += `\n\nNote: More results are available. To retrieve all pages, add the parameter 'fetchAll: true' to your request.`;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
content: [{ type: "text", text: resultText }],
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
logger.error(`Error in Lokka-Microsoft tool (apiType: ${apiType}, path: ${path}, method: ${method}):`, error); // Added more context to error log
|
|
228
|
+
// Try to determine the base URL even in case of error
|
|
229
|
+
if (!determinedUrl) {
|
|
230
|
+
determinedUrl = apiType === 'graph'
|
|
231
|
+
? `https://graph.microsoft.com/${effectiveGraphApiVersion}`
|
|
232
|
+
: "https://management.azure.com";
|
|
233
|
+
}
|
|
234
|
+
// Include error body if available from Graph SDK error
|
|
235
|
+
const errorBody = error.body ? (typeof error.body === 'string' ? error.body : JSON.stringify(error.body)) : 'N/A';
|
|
236
|
+
return {
|
|
237
|
+
content: [{
|
|
238
|
+
type: "text",
|
|
239
|
+
text: JSON.stringify({
|
|
240
|
+
error: error instanceof Error ? error.message : String(error),
|
|
241
|
+
statusCode: error.statusCode || 'N/A', // Include status code if available from SDK error
|
|
242
|
+
errorBody: errorBody,
|
|
243
|
+
attemptedBaseUrl: determinedUrl
|
|
244
|
+
}),
|
|
245
|
+
}],
|
|
246
|
+
isError: true
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
// Add token management tools
|
|
251
|
+
server.tool("set-access-token", "Set or update the access token for Microsoft Graph authentication. Use this when the MCP Client has obtained a fresh token through interactive authentication.", {
|
|
252
|
+
accessToken: z.string().describe("The access token obtained from Microsoft Graph authentication"),
|
|
253
|
+
expiresOn: z.string().optional().describe("Token expiration time in ISO format (optional, defaults to 1 hour from now)")
|
|
254
|
+
}, async ({ accessToken, expiresOn }) => {
|
|
255
|
+
try {
|
|
256
|
+
const expirationDate = expiresOn ? new Date(expiresOn) : undefined;
|
|
257
|
+
if (authManager?.getAuthMode() === AuthMode.ClientProvidedToken) {
|
|
258
|
+
authManager.updateAccessToken(accessToken, expirationDate);
|
|
259
|
+
// Reinitialize the Graph client with the new token
|
|
260
|
+
const authProvider = authManager.getGraphAuthProvider();
|
|
261
|
+
graphClient = Client.initWithMiddleware({
|
|
262
|
+
authProvider: authProvider,
|
|
263
|
+
});
|
|
264
|
+
return {
|
|
265
|
+
content: [{
|
|
266
|
+
type: "text",
|
|
267
|
+
text: "Access token updated successfully. You can now make Microsoft Graph requests on behalf of the authenticated user."
|
|
268
|
+
}],
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
return {
|
|
273
|
+
content: [{
|
|
274
|
+
type: "text",
|
|
275
|
+
text: "Error: MCP Server is not configured for client-provided token authentication. Set USE_CLIENT_TOKEN=true in environment variables."
|
|
276
|
+
}],
|
|
277
|
+
isError: true
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
logger.error("Error setting access token:", error);
|
|
283
|
+
return {
|
|
284
|
+
content: [{
|
|
285
|
+
type: "text",
|
|
286
|
+
text: `Error setting access token: ${error.message}`
|
|
287
|
+
}],
|
|
288
|
+
isError: true
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
server.tool("get-auth-status", "Check the current authentication status and mode of the MCP Server and also returns the current graph permission scopes of the access token for the current session.", {}, async () => {
|
|
293
|
+
try {
|
|
294
|
+
const authMode = authManager?.getAuthMode() || "Not initialized";
|
|
295
|
+
const isReady = authManager !== null;
|
|
296
|
+
const tokenStatus = authManager ? await authManager.getTokenStatus() : { isExpired: false };
|
|
297
|
+
return {
|
|
298
|
+
content: [{
|
|
299
|
+
type: "text",
|
|
300
|
+
text: JSON.stringify({
|
|
301
|
+
authMode,
|
|
302
|
+
isReady,
|
|
303
|
+
supportsTokenUpdates: authMode === AuthMode.ClientProvidedToken,
|
|
304
|
+
tokenStatus: tokenStatus,
|
|
305
|
+
timestamp: new Date().toISOString()
|
|
306
|
+
}, null, 2)
|
|
307
|
+
}],
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
return {
|
|
312
|
+
content: [{
|
|
313
|
+
type: "text",
|
|
314
|
+
text: `Error checking auth status: ${error.message}`
|
|
315
|
+
}],
|
|
316
|
+
isError: true
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
// Add tool for requesting additional Graph permissions
|
|
321
|
+
server.tool("add-graph-permission", "Request additional Microsoft Graph permission scopes by performing a fresh interactive sign-in. This tool only works in interactive authentication mode and should be used if any Graph API call returns permissions related errors.", {
|
|
322
|
+
scopes: z.array(z.string()).describe("Array of Microsoft Graph permission scopes to request (e.g., ['User.Read', 'Mail.ReadWrite', 'Directory.Read.All'])")
|
|
323
|
+
}, async ({ scopes }) => {
|
|
324
|
+
try {
|
|
325
|
+
// Check if we're in interactive mode
|
|
326
|
+
if (!authManager || authManager.getAuthMode() !== AuthMode.Interactive) {
|
|
327
|
+
const currentMode = authManager?.getAuthMode() || "Not initialized";
|
|
328
|
+
const clientId = process.env.CLIENT_ID;
|
|
329
|
+
let errorMessage = `Error: add-graph-permission tool is only available in interactive authentication mode. Current mode: ${currentMode}.\n\n`;
|
|
330
|
+
if (currentMode === AuthMode.ClientCredentials) {
|
|
331
|
+
errorMessage += `📋 To add permissions in Client Credentials mode:\n`;
|
|
332
|
+
errorMessage += `1. Open the Microsoft Entra admin center (https://entra.microsoft.com)\n`;
|
|
333
|
+
errorMessage += `2. Navigate to Applications > App registrations\n`;
|
|
334
|
+
errorMessage += `3. Find your application${clientId ? ` (Client ID: ${clientId})` : ''}\n`;
|
|
335
|
+
errorMessage += `4. Go to API permissions\n`;
|
|
336
|
+
errorMessage += `5. Click "Add a permission" and select Microsoft Graph\n`;
|
|
337
|
+
errorMessage += `6. Choose "Application permissions" and add the required scopes:\n`;
|
|
338
|
+
errorMessage += ` ${scopes.map(scope => `• ${scope}`).join('\n ')}\n`;
|
|
339
|
+
errorMessage += `7. Click "Grant admin consent" to approve the permissions\n`;
|
|
340
|
+
errorMessage += `8. Restart the MCP server to use the new permissions`;
|
|
341
|
+
}
|
|
342
|
+
else if (currentMode === AuthMode.ClientProvidedToken) {
|
|
343
|
+
errorMessage += `📋 To add permissions in Client Provided Token mode:\n`;
|
|
344
|
+
errorMessage += `1. Obtain a new access token that includes the required scopes:\n`;
|
|
345
|
+
errorMessage += ` ${scopes.map(scope => `• ${scope}`).join('\n ')}\n`;
|
|
346
|
+
errorMessage += `2. When obtaining the token, ensure these scopes are included in the consent prompt\n`;
|
|
347
|
+
errorMessage += `3. Use the set-access-token tool to update the server with the new token\n`;
|
|
348
|
+
errorMessage += `4. The new token will include the additional permissions`;
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
errorMessage += `To use interactive permission requests, set USE_INTERACTIVE=true in environment variables and restart the server.`;
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
content: [{
|
|
355
|
+
type: "text",
|
|
356
|
+
text: errorMessage
|
|
357
|
+
}],
|
|
358
|
+
isError: true
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
// Validate scopes array
|
|
362
|
+
if (!scopes || scopes.length === 0) {
|
|
363
|
+
return {
|
|
364
|
+
content: [{
|
|
365
|
+
type: "text",
|
|
366
|
+
text: "Error: At least one permission scope must be specified."
|
|
367
|
+
}],
|
|
368
|
+
isError: true
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
// Validate scope format (basic validation)
|
|
372
|
+
const invalidScopes = scopes.filter(scope => !scope.includes('.') || scope.trim() !== scope);
|
|
373
|
+
if (invalidScopes.length > 0) {
|
|
374
|
+
return {
|
|
375
|
+
content: [{
|
|
376
|
+
type: "text",
|
|
377
|
+
text: `Error: Invalid scope format detected: ${invalidScopes.join(', ')}. Scopes should be in format like 'User.Read' or 'Mail.ReadWrite'.`
|
|
378
|
+
}],
|
|
379
|
+
isError: true
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
logger.info(`Requesting additional Graph permissions: ${scopes.join(', ')}`);
|
|
383
|
+
// Get current configuration with defaults for interactive auth
|
|
384
|
+
const tenantId = process.env.TENANT_ID || LokkaDefaultTenantId;
|
|
385
|
+
const clientId = process.env.CLIENT_ID || LokkaClientId;
|
|
386
|
+
const redirectUri = process.env.REDIRECT_URI || LokkaDefaultRedirectUri;
|
|
387
|
+
logger.info(`Using tenant ID: ${tenantId}, client ID: ${clientId} for interactive authentication`);
|
|
388
|
+
// Create a new interactive credential with the requested scopes
|
|
389
|
+
const { InteractiveBrowserCredential, DeviceCodeCredential } = await import("@azure/identity");
|
|
390
|
+
// Clear any existing auth manager to force fresh authentication
|
|
391
|
+
authManager = null;
|
|
392
|
+
graphClient = null;
|
|
393
|
+
// Request token with the new scopes - this will trigger interactive authentication
|
|
394
|
+
const scopeString = scopes.map(scope => `https://graph.microsoft.com/${scope}`).join(' ');
|
|
395
|
+
logger.info(`Requesting fresh token with scopes: ${scopeString}`);
|
|
396
|
+
console.log(`\n🔐 Requesting Additional Graph Permissions:`);
|
|
397
|
+
console.log(`Scopes: ${scopes.join(', ')}`);
|
|
398
|
+
console.log(`You will be prompted to sign in to grant these permissions.\n`);
|
|
399
|
+
let newCredential;
|
|
400
|
+
let tokenResponse;
|
|
401
|
+
try {
|
|
402
|
+
// Try Interactive Browser first - create fresh instance each time
|
|
403
|
+
newCredential = new InteractiveBrowserCredential({
|
|
404
|
+
tenantId: tenantId,
|
|
405
|
+
clientId: clientId,
|
|
406
|
+
redirectUri: redirectUri,
|
|
407
|
+
});
|
|
408
|
+
// Request token immediately after creating credential
|
|
409
|
+
tokenResponse = await newCredential.getToken(scopeString);
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
// Fallback to Device Code flow
|
|
413
|
+
logger.info("Interactive browser failed, falling back to device code flow");
|
|
414
|
+
newCredential = new DeviceCodeCredential({
|
|
415
|
+
tenantId: tenantId,
|
|
416
|
+
clientId: clientId,
|
|
417
|
+
userPromptCallback: (info) => {
|
|
418
|
+
console.log(`\n🔐 Additional Permissions Required:`);
|
|
419
|
+
console.log(`Please visit: ${info.verificationUri}`);
|
|
420
|
+
console.log(`And enter code: ${info.userCode}`);
|
|
421
|
+
console.log(`Requested scopes: ${scopes.join(', ')}\n`);
|
|
422
|
+
return Promise.resolve();
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
// Request token with device code credential
|
|
426
|
+
tokenResponse = await newCredential.getToken(scopeString);
|
|
427
|
+
}
|
|
428
|
+
if (!tokenResponse) {
|
|
429
|
+
return {
|
|
430
|
+
content: [{
|
|
431
|
+
type: "text",
|
|
432
|
+
text: "Error: Failed to acquire access token with the requested scopes. Please check your permissions and try again."
|
|
433
|
+
}],
|
|
434
|
+
isError: true
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
// Create a completely new auth manager instance with the updated credential
|
|
438
|
+
const authConfig = {
|
|
439
|
+
mode: AuthMode.Interactive,
|
|
440
|
+
tenantId,
|
|
441
|
+
clientId,
|
|
442
|
+
redirectUri
|
|
443
|
+
};
|
|
444
|
+
// Create a new auth manager instance
|
|
445
|
+
authManager = new AuthManager(authConfig);
|
|
446
|
+
// Manually set the credential to our new one with the additional scopes
|
|
447
|
+
authManager.credential = newCredential;
|
|
448
|
+
// DO NOT call initialize() as it might interfere with our fresh token
|
|
449
|
+
// Instead, directly create the Graph client with the new credential
|
|
450
|
+
const authProvider = authManager.getGraphAuthProvider();
|
|
451
|
+
graphClient = Client.initWithMiddleware({
|
|
452
|
+
authProvider: authProvider,
|
|
453
|
+
});
|
|
454
|
+
// Get the token status to show the new scopes
|
|
455
|
+
const tokenStatus = await authManager.getTokenStatus();
|
|
456
|
+
logger.info(`Successfully acquired fresh token with additional scopes: ${scopes.join(', ')}`);
|
|
457
|
+
return {
|
|
458
|
+
content: [{
|
|
459
|
+
type: "text",
|
|
460
|
+
text: JSON.stringify({
|
|
461
|
+
message: "Successfully acquired additional Microsoft Graph permissions with fresh authentication",
|
|
462
|
+
requestedScopes: scopes,
|
|
463
|
+
tokenStatus: tokenStatus,
|
|
464
|
+
note: "A fresh sign-in was performed to ensure the new permissions are properly granted",
|
|
465
|
+
timestamp: new Date().toISOString()
|
|
466
|
+
}, null, 2)
|
|
467
|
+
}],
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
catch (error) {
|
|
471
|
+
logger.error("Error requesting additional Graph permissions:", error);
|
|
472
|
+
return {
|
|
473
|
+
content: [{
|
|
474
|
+
type: "text",
|
|
475
|
+
text: `Error requesting additional permissions: ${error.message}`
|
|
476
|
+
}],
|
|
477
|
+
isError: true
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
// Start the server with stdio transport
|
|
482
|
+
async function main() {
|
|
483
|
+
// Determine authentication mode based on environment variables
|
|
484
|
+
const useCertificate = process.env.USE_CERTIFICATE === 'true';
|
|
485
|
+
const useInteractive = process.env.USE_INTERACTIVE === 'true';
|
|
486
|
+
const useClientToken = process.env.USE_CLIENT_TOKEN === 'true';
|
|
487
|
+
const initialAccessToken = process.env.ACCESS_TOKEN;
|
|
488
|
+
let authMode;
|
|
489
|
+
// Ensure only one authentication mode is enabled at a time
|
|
490
|
+
const enabledModes = [
|
|
491
|
+
useClientToken,
|
|
492
|
+
useInteractive,
|
|
493
|
+
useCertificate
|
|
494
|
+
].filter(Boolean);
|
|
495
|
+
if (enabledModes.length > 1) {
|
|
496
|
+
throw new Error("Multiple authentication modes enabled. Please enable only one of USE_CLIENT_TOKEN, USE_INTERACTIVE, or USE_CERTIFICATE.");
|
|
497
|
+
}
|
|
498
|
+
if (useClientToken) {
|
|
499
|
+
authMode = AuthMode.ClientProvidedToken;
|
|
500
|
+
if (!initialAccessToken) {
|
|
501
|
+
logger.info("Client token mode enabled but no initial token provided. Token must be set via set-access-token tool.");
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
else if (useInteractive) {
|
|
505
|
+
authMode = AuthMode.Interactive;
|
|
506
|
+
}
|
|
507
|
+
else if (useCertificate) {
|
|
508
|
+
authMode = AuthMode.Certificate;
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
// Check if we have client credentials environment variables
|
|
512
|
+
const hasClientCredentials = process.env.TENANT_ID && process.env.CLIENT_ID && process.env.CLIENT_SECRET;
|
|
513
|
+
if (hasClientCredentials) {
|
|
514
|
+
authMode = AuthMode.ClientCredentials;
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
// Default to persistent token mode — caches tokens to disk and refreshes silently.
|
|
518
|
+
// On first run (no token file), it will open a browser for interactive auth.
|
|
519
|
+
authMode = AuthMode.PersistentToken;
|
|
520
|
+
const hasTokenFile = fs.existsSync(LokkaTokenPath);
|
|
521
|
+
logger.info(hasTokenFile
|
|
522
|
+
? "Found cached token file, using persistent token mode (silent refresh)."
|
|
523
|
+
: "No cached token found, persistent token mode will open browser for initial auth.");
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
logger.info(`Starting with authentication mode: ${authMode}`);
|
|
527
|
+
// Get tenant ID and client ID with defaults only for interactive mode
|
|
528
|
+
let tenantId;
|
|
529
|
+
let clientId;
|
|
530
|
+
if (authMode === AuthMode.Interactive || authMode === AuthMode.PersistentToken) {
|
|
531
|
+
// Interactive and persistent token modes can use defaults
|
|
532
|
+
tenantId = process.env.TENANT_ID || LokkaDefaultTenantId;
|
|
533
|
+
clientId = process.env.CLIENT_ID || LokkaClientId;
|
|
534
|
+
logger.info(`${authMode} mode using tenant ID: ${tenantId}, client ID: ${clientId}`);
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
// All other modes require explicit values from environment variables
|
|
538
|
+
tenantId = process.env.TENANT_ID;
|
|
539
|
+
clientId = process.env.CLIENT_ID;
|
|
540
|
+
}
|
|
541
|
+
const clientSecret = process.env.CLIENT_SECRET;
|
|
542
|
+
const certificatePath = process.env.CERTIFICATE_PATH;
|
|
543
|
+
const certificatePassword = process.env.CERTIFICATE_PASSWORD; // optional
|
|
544
|
+
// Validate required configuration
|
|
545
|
+
if (authMode === AuthMode.ClientCredentials) {
|
|
546
|
+
if (!tenantId || !clientId || !clientSecret) {
|
|
547
|
+
throw new Error("Client credentials mode requires explicit TENANT_ID, CLIENT_ID, and CLIENT_SECRET environment variables");
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
else if (authMode === AuthMode.Certificate) {
|
|
551
|
+
if (!tenantId || !clientId || !certificatePath) {
|
|
552
|
+
throw new Error("Certificate mode requires explicit TENANT_ID, CLIENT_ID, and CERTIFICATE_PATH environment variables");
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
// Note: Client token mode can start without a token and receive it later
|
|
556
|
+
const authConfig = {
|
|
557
|
+
mode: authMode,
|
|
558
|
+
tenantId,
|
|
559
|
+
clientId,
|
|
560
|
+
clientSecret,
|
|
561
|
+
accessToken: initialAccessToken,
|
|
562
|
+
redirectUri: process.env.REDIRECT_URI,
|
|
563
|
+
certificatePath,
|
|
564
|
+
certificatePassword
|
|
565
|
+
};
|
|
566
|
+
authManager = new AuthManager(authConfig);
|
|
567
|
+
// Only initialize if we have required config (for client token mode, we can start without a token)
|
|
568
|
+
if (authMode !== AuthMode.ClientProvidedToken || initialAccessToken) {
|
|
569
|
+
await authManager.initialize();
|
|
570
|
+
// Initialize Graph Client
|
|
571
|
+
const authProvider = authManager.getGraphAuthProvider();
|
|
572
|
+
graphClient = Client.initWithMiddleware({
|
|
573
|
+
authProvider: authProvider,
|
|
574
|
+
});
|
|
575
|
+
logger.info(`Authentication initialized successfully using ${authMode} mode`);
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
logger.info("Started in client token mode. Use set-access-token tool to provide authentication token.");
|
|
579
|
+
}
|
|
580
|
+
const transport = new StdioServerTransport();
|
|
581
|
+
await server.connect(transport);
|
|
582
|
+
}
|
|
583
|
+
main().catch((error) => {
|
|
584
|
+
console.error("Fatal error in main():", error);
|
|
585
|
+
logger.error("Fatal error in main()", error);
|
|
586
|
+
process.exit(1);
|
|
587
|
+
});
|