UNPKG

@houmak/minerva-mcp-server

Version:

Minerva Model Context Protocol (MCP) Server for Microsoft 365 and Azure integrations

936 lines (935 loc) 76.7 kB
#!/usr/bin/env node import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import express from "express"; import cors from "cors"; import { spawn } from "child_process"; // Optional HTTP bridge: if START_HTTP_BRIDGE=true, start express server that proxies to this stdio server via a child process per request. import { z } from "zod"; import { Client, PageIterator } from "@microsoft/microsoft-graph-client"; // import fetch from 'isomorphic-fetch'; // Required polyfill for Graph client import { logger } from "./logger.js"; import { AuthManager, AuthMode } from "./auth.js"; import { LokkaClientId, LokkaDefaultTenantId, LokkaDefaultRedirectUri, getDefaultGraphApiVersion } from "./constants.js"; import { createPowerShellExecutor } from "./powershell-executor.js"; import { dirname } from "path"; import { fileURLToPath } from "url"; import { registerMigrationTools } from "./migration-tools.js"; import { registerPnPTools } from "./pnp-integration.js"; import { registerAllPnPTools } from "./pnp-tools/index.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Set up global fetch for the Microsoft Graph client global.fetch = fetch; // Create server instance const server = new McpServer({ name: "Minerva-Microsoft", version: "0.1.0", // Minerva version with PowerShell/PnP support }); // MCP protocol requires only JSON messages via stdio, so we disable all non-JSON output // console.error("Starting Minerva Extended Microsoft API MCP Server (v0.1.0 - PowerShell/PnP Support)"); // Initialize authentication and clients let authManager = null; let graphClient = null; let powershellExecutor = null; // Check USE_GRAPH_BETA environment variable const useGraphBeta = process.env.USE_GRAPH_BETA !== 'false'; // Default to true unless explicitly set to 'false' const defaultGraphApiVersion = getDefaultGraphApiVersion(); // console.error(`Graph API default version: ${defaultGraphApiVersion} (USE_GRAPH_BETA=${process.env.USE_GRAPH_BETA || 'undefined'})`); server.tool("Minerva-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\"'.", { apiType: z.enum(["graph", "azure"]).describe("Type of Microsoft API to query. Options: 'graph' for Microsoft Graph (Entra) or 'azure' for Azure Resource Management."), path: z.string().describe("The Azure or Graph API URL path to call (e.g. '/users', '/groups', '/subscriptions')"), method: z.enum(["get", "post", "put", "patch", "delete"]).describe("HTTP method to use"), apiVersion: z.string().optional().describe("Azure Resource Management API version (required for apiType Azure)"), subscriptionId: z.string().optional().describe("Azure Subscription ID (for Azure Resource Management)."), queryParams: z.record(z.string()).optional().describe("Query parameters for the request"), body: z.record(z.string(), z.any()).optional().describe("The request body (for POST, PUT, PATCH)"), graphApiVersion: z.enum(["v1.0", "beta"]).optional().default(defaultGraphApiVersion).describe(`Microsoft Graph API version to use (default: ${defaultGraphApiVersion})`), 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."), 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)."), }, async ({ apiType, path, method, apiVersion, subscriptionId, queryParams, body, graphApiVersion, fetchAll, consistencyLevel }) => { // Override graphApiVersion if USE_GRAPH_BETA is explicitly set to false const effectiveGraphApiVersion = !useGraphBeta ? "v1.0" : graphApiVersion; // console.error(`Executing Lokka-Microsoft tool with params: apiType=${apiType}, path=${path}, method=${method}, graphApiVersion=${effectiveGraphApiVersion}, fetchAll=${fetchAll}, consistencyLevel=${consistencyLevel}`); let determinedUrl; try { let responseData; // --- Microsoft Graph Logic --- if (apiType === 'graph') { if (!graphClient) { throw new Error("Graph client not initialized"); } determinedUrl = `https://graph.microsoft.com/${effectiveGraphApiVersion}`; // For error reporting // Construct the request using the Graph SDK client let request = graphClient.api(path).version(effectiveGraphApiVersion); // Add query parameters if provided and not empty if (queryParams && Object.keys(queryParams).length > 0) { request = request.query(queryParams); } // Optimize for large datasets like groups if (path === '/groups' && fetchAll) { // Add $top parameter to limit page size for groups if not already specified if (!queryParams || !queryParams['$top']) { request = request.query({ '$top': '100' }); // Smaller page size for groups } // Ensure we have essential fields only for groups to reduce response size if (!queryParams || !queryParams['$select']) { request = request.query({ '$select': 'id,displayName,createdDateTime,description,groupTypes,mailEnabled,securityEnabled,visibility' }); } } // Add ConsistencyLevel header if provided if (consistencyLevel) { request = request.header('ConsistencyLevel', consistencyLevel); // console.error(`Added ConsistencyLevel header: ${consistencyLevel}`); } // Handle different methods switch (method.toLowerCase()) { case 'get': if (fetchAll) { // console.error(`Fetching all pages for Graph path: ${path}`); // Enhanced safety limits to prevent response size overflow const MAX_ITEMS = 5000; // Reduced from 10000 to prevent 1MB limit const MAX_PAGES = 25; // Reduced from 50 to prevent excessive memory usage const MAX_RESPONSE_SIZE = 900000; // 900KB limit to stay under 1MB // Fetch the first page to get context and initial data const firstPageResponse = await request.get(); const odataContext = firstPageResponse['@odata.context']; // Capture context from first page let allItems = firstPageResponse.value || []; // Initialize with first page's items let pageCount = 1; // Check response size and apply size-based limits const estimateResponseSize = (items) => { return JSON.stringify(items).length; }; // Check if we already exceed limits on first page if (allItems.length >= MAX_ITEMS || estimateResponseSize(allItems) >= MAX_RESPONSE_SIZE) { // Truncate based on whichever limit is hit first let truncatedItems = allItems; let truncationReason = ''; if (allItems.length >= MAX_ITEMS) { truncatedItems = allItems.slice(0, MAX_ITEMS); truncationReason = `item limit (${MAX_ITEMS})`; } else { // Find the maximum number of items that fit within size limit let maxItems = allItems.length; while (maxItems > 0 && estimateResponseSize(allItems.slice(0, maxItems)) >= MAX_RESPONSE_SIZE) { maxItems--; } truncatedItems = allItems.slice(0, maxItems); truncationReason = `response size limit (${Math.round(estimateResponseSize(truncatedItems) / 1024)}KB)`; } responseData = { '@odata.context': odataContext, value: truncatedItems, warning: `Results truncated to ${truncatedItems.length} items due to ${truncationReason}. Use pagination with smaller page sizes for complete datasets.`, pagination: { totalItems: allItems.length, returnedItems: truncatedItems.length, hasMore: true, nextLink: firstPageResponse['@odata.nextLink'] } }; } else { // Callback function to process subsequent pages with enhanced safety limits const callback = (item) => { if (allItems.length >= MAX_ITEMS) { return false; // Stop iteration } // Check response size before adding item const newItems = [...allItems, item]; if (estimateResponseSize(newItems) >= MAX_RESPONSE_SIZE) { return false; // Stop iteration due to size limit } allItems.push(item); return true; // Continue iteration }; // Create a PageIterator starting from the first response const pageIterator = new PageIterator(graphClient, firstPageResponse, callback); // Iterate over remaining pages with enhanced limits let hasMorePages = true; while (hasMorePages && pageCount < MAX_PAGES && allItems.length < MAX_ITEMS) { try { hasMorePages = await pageIterator.iterate(); pageCount++; // Check response size after each page if (estimateResponseSize(allItems) >= MAX_RESPONSE_SIZE) { break; } // Log progress for large datasets if (pageCount % 5 === 0) { // console.error(`Processed ${pageCount} pages, ${allItems.length} items, ~${Math.round(estimateResponseSize(allItems) / 1024)}KB`); } } catch (error) { logger.error(`Error during pagination at page ${pageCount}:`, error); break; } } // Construct final response with context and enhanced safety warnings responseData = { '@odata.context': odataContext, value: allItems }; // Add comprehensive warnings for truncated results const warnings = []; if (pageCount >= MAX_PAGES) { warnings.push(`Results truncated after ${MAX_PAGES} pages`); } if (allItems.length >= MAX_ITEMS) { warnings.push(`Results truncated to ${MAX_ITEMS} items`); } if (estimateResponseSize(allItems) >= MAX_RESPONSE_SIZE) { warnings.push(`Results truncated due to response size limit (~${Math.round(estimateResponseSize(allItems) / 1024)}KB)`); } if (warnings.length > 0) { responseData.warning = `${warnings.join(', ')}. Use pagination with smaller page sizes for complete datasets.`; responseData.pagination = { totalItems: 'unknown', returnedItems: allItems.length, hasMore: hasMorePages, nextLink: firstPageResponse['@odata.nextLink'] }; } } // console.error(`Finished fetching Graph pages. Pages: ${pageCount}, Total items: ${allItems.length}, Size: ~${Math.round(estimateResponseSize(allItems) / 1024)}KB`); } else { // console.error(`Fetching single page for Graph path: ${path}`); responseData = await request.get(); } break; case 'post': responseData = await request.post(body ?? {}); break; case 'put': responseData = await request.put(body ?? {}); break; case 'patch': responseData = await request.patch(body ?? {}); break; case 'delete': responseData = await request.delete(); // Delete often returns no body or 204 // Handle potential 204 No Content response if (responseData === undefined || responseData === null) { responseData = { status: "Success (No Content)" }; } break; default: throw new Error(`Unsupported method: ${method}`); } } // --- Azure Resource Management Logic (using direct fetch) --- else { // apiType === 'azure' if (!authManager) { throw new Error("Auth manager not initialized"); } determinedUrl = "https://management.azure.com"; // For error reporting // Acquire token for Azure RM const azureCredential = authManager.getAzureCredential(); const tokenResponse = await azureCredential.getToken("https://management.azure.com/.default"); if (!tokenResponse || !tokenResponse.token) { throw new Error("Failed to acquire Azure access token"); } // Construct the URL (similar to previous implementation) let url = determinedUrl; if (subscriptionId) { url += `/subscriptions/${subscriptionId}`; } url += path; if (!apiVersion) { throw new Error("API version is required for Azure Resource Management queries"); } const urlParams = new URLSearchParams({ 'api-version': apiVersion }); if (queryParams) { for (const [key, value] of Object.entries(queryParams)) { urlParams.append(String(key), String(value)); } } url += `?${urlParams.toString()}`; // Prepare request options const headers = { 'Authorization': `Bearer ${tokenResponse.token}`, 'Content-Type': 'application/json' }; const requestOptions = { method: method.toUpperCase(), headers: headers }; if (["POST", "PUT", "PATCH"].includes(method.toUpperCase())) { requestOptions.body = body ? JSON.stringify(body) : JSON.stringify({}); } // --- Pagination Logic for Azure RM (Manual Fetch) --- if (fetchAll && method === 'get') { console.error(`Fetching all pages for Azure RM starting from: ${url}`); let allValues = []; let currentUrl = url; while (currentUrl) { console.error(`Fetching Azure RM page: ${currentUrl}`); // Re-acquire token for each page (Azure tokens might expire) const azureCredential = authManager.getAzureCredential(); const currentPageTokenResponse = await azureCredential.getToken("https://management.azure.com/.default"); if (!currentPageTokenResponse || !currentPageTokenResponse.token) { throw new Error("Failed to acquire Azure access token during pagination"); } const currentPageHeaders = { ...headers, 'Authorization': `Bearer ${currentPageTokenResponse.token}` }; const currentPageRequestOptions = { method: 'GET', headers: currentPageHeaders }; const pageResponse = await fetch(currentUrl, currentPageRequestOptions); const pageText = await pageResponse.text(); let pageData; try { pageData = pageText ? JSON.parse(pageText) : {}; } catch (e) { logger.error(`Failed to parse JSON from Azure RM page: ${currentUrl}`, pageText); pageData = { rawResponse: pageText }; } if (!pageResponse.ok) { logger.error(`API error on Azure RM page ${currentUrl}:`, pageData); throw new Error(`API error (${pageResponse.status}) during Azure RM pagination on ${currentUrl}: ${JSON.stringify(pageData)}`); } if (pageData.value && Array.isArray(pageData.value)) { allValues = allValues.concat(pageData.value); } else if (currentUrl === url && !pageData.nextLink) { allValues.push(pageData); } else if (currentUrl !== url) { console.error(`[Warning] Azure RM response from ${currentUrl} did not contain a 'value' array.`); } currentUrl = pageData.nextLink || null; // Azure uses nextLink } responseData = { allValues: allValues }; console.error(`Finished fetching all Azure RM pages. Total items: ${allValues.length}`); } else { // Single page fetch for Azure RM console.error(`Fetching single page for Azure RM: ${url}`); const apiResponse = await fetch(url, requestOptions); const responseText = await apiResponse.text(); try { responseData = responseText ? JSON.parse(responseText) : {}; } catch (e) { logger.error(`Failed to parse JSON from single Azure RM page: ${url}`, responseText); responseData = { rawResponse: responseText }; } if (!apiResponse.ok) { logger.error(`API error for Azure RM ${method} ${path}:`, responseData); throw new Error(`API error (${apiResponse.status}) for Azure RM: ${JSON.stringify(responseData)}`); } } } // --- Format and Return Result --- // For all requests, format as text let resultText = `Result for ${apiType} API (${apiType === 'graph' ? effectiveGraphApiVersion : apiVersion}) - ${method} ${path}:\n\n`; resultText += JSON.stringify(responseData, null, 2); // responseData already contains the correct structure for fetchAll Graph case // Add pagination note if applicable (only for single page GET) if (!fetchAll && method === 'get') { const nextLinkKey = apiType === 'graph' ? '@odata.nextLink' : 'nextLink'; if (responseData && responseData[nextLinkKey]) { // Added check for responseData existence resultText += `\n\nNote: More results are available. To retrieve all pages, add the parameter 'fetchAll: true' to your request.`; } } return { content: [{ type: "text", text: resultText }], }; } catch (error) { logger.error(`Error in Lokka-Microsoft tool (apiType: ${apiType}, path: ${path}, method: ${method}):`, error); // Added more context to error log // Try to determine the base URL even in case of error if (!determinedUrl) { determinedUrl = apiType === 'graph' ? `https://graph.microsoft.com/${effectiveGraphApiVersion}` : "https://management.azure.com"; } // Include error body if available from Graph SDK error const errorBody = error.body ? (typeof error.body === 'string' ? error.body : JSON.stringify(error.body)) : 'N/A'; return { content: [{ type: "text", text: JSON.stringify({ error: error instanceof Error ? error.message : String(error), statusCode: error.statusCode || 'N/A', // Include status code if available from SDK error errorBody: errorBody, attemptedBaseUrl: determinedUrl }), }], isError: true }; } }); // Tool: Optimized Groups API for Large Datasets server.tool("get-groups-optimized", "Get Microsoft 365 groups with optimized pagination and response size management to avoid 1MB limit errors", { groupType: z.enum(["Unified", "SecurityEnabled", "MailEnabled", "All"]).default("Unified").describe("Type of groups to retrieve"), pageSize: z.number().min(1).max(200).default(50).describe("Number of groups per page (1-200)"), maxPages: z.number().min(1).max(20).default(10).describe("Maximum number of pages to fetch (1-20)"), includeDetails: z.boolean().default(false).describe("Include detailed group information (may increase response size)"), filter: z.string().optional().describe("Additional filter criteria (e.g., 'displayName eq \"Test Group\"')") }, async ({ groupType, pageSize, maxPages, includeDetails, filter }) => { try { if (!graphClient) { throw new Error("Graph client not initialized"); } // Build optimized query parameters const queryParams = { '$top': pageSize.toString(), '$select': includeDetails ? 'id,displayName,createdDateTime,description,groupTypes,mailEnabled,securityEnabled,visibility,mail,preferredLanguage,proxyAddresses,renewedDateTime,resourceBehaviorOptions,resourceProvisioningOptions,securityIdentifier,theme,visibility' : 'id,displayName,createdDateTime,description,groupTypes,mailEnabled,securityEnabled,visibility' }; // Add group type filter if (groupType === "Unified") { queryParams['$filter'] = "groupTypes/any(c:c eq 'Unified')"; } else if (groupType === "SecurityEnabled") { queryParams['$filter'] = "securityEnabled eq true"; } else if (groupType === "MailEnabled") { queryParams['$filter'] = "mailEnabled eq true"; } // Add additional filter if provided if (filter) { const existingFilter = queryParams['$filter']; queryParams['$filter'] = existingFilter ? `(${existingFilter}) and (${filter})` : filter; } let allGroups = []; let currentPage = 1; let nextLink = null; const MAX_RESPONSE_SIZE = 800000; // 800KB limit to stay well under 1MB // Function to estimate response size const estimateResponseSize = (items) => { return JSON.stringify(items).length; }; do { try { // Build request for current page let request = graphClient.api('/groups').version('v1.0'); if (nextLink) { // Use nextLink for subsequent pages request = graphClient.api(nextLink); } else { // Add query parameters for first page Object.entries(queryParams).forEach(([key, value]) => { request = request.query({ [key]: value }); }); } const response = await request.get(); const groups = response.value || []; // Check if adding this page would exceed size limit const newGroups = [...allGroups, ...groups]; if (estimateResponseSize(newGroups) >= MAX_RESPONSE_SIZE) { // Truncate to fit within size limit let maxGroups = allGroups.length; while (maxGroups < newGroups.length && estimateResponseSize(newGroups.slice(0, maxGroups + 1)) < MAX_RESPONSE_SIZE) { maxGroups++; } allGroups = newGroups.slice(0, maxGroups); return { content: [{ type: "text", text: JSON.stringify({ value: allGroups, '@odata.context': response['@odata.context'], warning: `Results truncated to ${allGroups.length} groups due to response size limit (~${Math.round(estimateResponseSize(allGroups) / 1024)}KB). Use smaller page sizes or filters for complete datasets.`, pagination: { totalPages: currentPage, returnedGroups: allGroups.length, hasMore: true, nextLink: response['@odata.nextLink'] } }, null, 2) }] }; } allGroups = newGroups; nextLink = response['@odata.nextLink']; currentPage++; } catch (error) { logger.error(`Error fetching groups page ${currentPage}:`, error); break; } } while (nextLink && currentPage <= maxPages); return { content: [{ type: "text", text: JSON.stringify({ value: allGroups, '@odata.context': `https://graph.microsoft.com/v1.0/$metadata#groups`, pagination: { totalPages: currentPage - 1, returnedGroups: allGroups.length, hasMore: !!nextLink, nextLink: nextLink } }, null, 2) }] }; } catch (error) { logger.error("Error in get-groups-optimized:", error); return { content: [{ type: "text", text: JSON.stringify({ error: error.message, statusCode: error.statusCode || 'N/A' }, null, 2) }], isError: true }; } }); // Add token management tools 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.", { accessToken: z.string().describe("The access token obtained from Microsoft Graph authentication"), expiresOn: z.string().optional().describe("Token expiration time in ISO format (optional, defaults to 1 hour from now)") }, async ({ accessToken, expiresOn }) => { try { const expirationDate = expiresOn ? new Date(expiresOn) : undefined; if (authManager?.getAuthMode() === AuthMode.ClientProvidedToken) { authManager.updateAccessToken(accessToken, expirationDate); // Reinitialize the Graph client with the new token const authProvider = authManager.getGraphAuthProvider(); graphClient = Client.initWithMiddleware({ authProvider: authProvider, }); return { content: [{ type: "text", text: "Access token updated successfully. You can now make Microsoft Graph requests on behalf of the authenticated user." }], }; } else { return { content: [{ type: "text", text: "Error: MCP Server is not configured for client-provided token authentication. Set USE_CLIENT_TOKEN=true in environment variables." }], isError: true }; } } catch (error) { logger.error("Error setting access token:", error); return { content: [{ type: "text", text: `Error setting access token: ${error.message}` }], isError: true }; } }); 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 () => { try { const authMode = authManager?.getAuthMode() || "Not initialized"; const isReady = authManager !== null; const tokenStatus = authManager ? await authManager.getTokenStatus() : { isExpired: false }; return { content: [{ type: "text", text: JSON.stringify({ authMode, isReady, supportsTokenUpdates: authMode === AuthMode.ClientProvidedToken, tokenStatus: tokenStatus, timestamp: new Date().toISOString() }, null, 2) }], }; } catch (error) { return { content: [{ type: "text", text: `Error checking auth status: ${error.message}` }], isError: true }; } }); // Add tool for requesting additional Graph permissions 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.", { scopes: z.array(z.string()).describe("Array of Microsoft Graph permission scopes to request (e.g., ['User.Read', 'Mail.ReadWrite', 'Directory.Read.All'])") }, async ({ scopes }) => { try { // Check if we're in interactive mode if (!authManager || authManager.getAuthMode() !== AuthMode.Interactive) { const currentMode = authManager?.getAuthMode() || "Not initialized"; const clientId = process.env.CLIENT_ID; let errorMessage = `Error: add-graph-permission tool is only available in interactive authentication mode. Current mode: ${currentMode}.\n\n`; if (currentMode === AuthMode.ClientCredentials) { errorMessage += `To add permissions in Client Credentials mode:\n`; errorMessage += `1. Open the Microsoft Entra admin center (https://entra.microsoft.com)\n`; errorMessage += `2. Navigate to Applications > App registrations\n`; errorMessage += `3. Find your application${clientId ? ` (Client ID: ${clientId})` : ''}\n`; errorMessage += `4. Go to API permissions\n`; errorMessage += `5. Click "Add a permission" and select Microsoft Graph\n`; errorMessage += `6. Choose "Application permissions" and add the required scopes:\n`; errorMessage += ` ${scopes.map(scope => `• ${scope}`).join('\n ')}\n`; errorMessage += `7. Click "Grant admin consent" to approve the permissions\n`; errorMessage += `8. Restart the MCP server to use the new permissions`; } else if (currentMode === AuthMode.ClientProvidedToken) { errorMessage += `To add permissions in Client Provided Token mode:\n`; errorMessage += `1. Obtain a new access token that includes the required scopes:\n`; errorMessage += ` ${scopes.map(scope => `• ${scope}`).join('\n ')}\n`; errorMessage += `2. When obtaining the token, ensure these scopes are included in the consent prompt\n`; errorMessage += `3. Use the set-access-token tool to update the server with the new token\n`; errorMessage += `4. The new token will include the additional permissions`; } else { errorMessage += `To use interactive permission requests, set USE_INTERACTIVE=true in environment variables and restart the server.`; } return { content: [{ type: "text", text: errorMessage }], isError: true }; } // Validate scopes array if (!scopes || scopes.length === 0) { return { content: [{ type: "text", text: "Error: At least one permission scope must be specified." }], isError: true }; } // Validate scope format (basic validation) const invalidScopes = scopes.filter(scope => !scope.includes('.') || scope.trim() !== scope); if (invalidScopes.length > 0) { return { content: [{ type: "text", text: `Error: Invalid scope format detected: ${invalidScopes.join(', ')}. Scopes should be in format like 'User.Read' or 'Mail.ReadWrite'.` }], isError: true }; } console.error(`Requesting additional Graph permissions: ${scopes.join(', ')}`); // Get current configuration with defaults for interactive auth const tenantId = process.env.TENANT_ID || LokkaDefaultTenantId; const clientId = process.env.CLIENT_ID || LokkaClientId; const redirectUri = process.env.REDIRECT_URI || LokkaDefaultRedirectUri; console.error(`Using tenant ID: ${tenantId}, client ID: ${clientId} for interactive authentication`); // Create a new interactive credential with the requested scopes const { InteractiveBrowserCredential, DeviceCodeCredential } = await import("@azure/identity"); // Clear any existing auth manager to force fresh authentication authManager = null; graphClient = null; // Request token with the new scopes - this will trigger interactive authentication const scopeString = scopes.map(scope => `https://graph.microsoft.com/${scope}`).join(' '); console.error(`Requesting fresh token with scopes: ${scopeString}`); console.error(`\n🔐 Requesting Additional Graph Permissions:`); console.error(`Scopes: ${scopes.join(', ')}`); console.error(`You will be prompted to sign in to grant these permissions.\n`); let newCredential; let tokenResponse; try { // Try Interactive Browser first - create fresh instance each time newCredential = new InteractiveBrowserCredential({ tenantId: tenantId, clientId: clientId, redirectUri: redirectUri, }); // Request token immediately after creating credential tokenResponse = await newCredential.getToken(scopeString); } catch (error) { // Fallback to Device Code flow console.error("Interactive browser failed, falling back to device code flow"); newCredential = new DeviceCodeCredential({ tenantId: tenantId, clientId: clientId, userPromptCallback: (info) => { console.error(`\n🔐 Additional Permissions Required:`); console.error(`Please visit: ${info.verificationUri}`); console.error(`And enter code: ${info.userCode}`); console.error(`Requested scopes: ${scopes.join(', ')}\n`); return Promise.resolve(); }, }); // Request token with device code credential tokenResponse = await newCredential.getToken(scopeString); } if (!tokenResponse) { return { content: [{ type: "text", text: "Error: Failed to acquire access token with the requested scopes. Please check your permissions and try again." }], isError: true }; } // Create a completely new auth manager instance with the updated credential const authConfig = { mode: AuthMode.Interactive, tenantId, clientId, redirectUri }; // Create a new auth manager instance authManager = new AuthManager(authConfig); // Manually set the credential to our new one with the additional scopes authManager.credential = newCredential; // DO NOT call initialize() as it might interfere with our fresh token // Instead, directly create the Graph client with the new credential const authProvider = authManager.getGraphAuthProvider(); graphClient = Client.initWithMiddleware({ authProvider: authProvider, }); // Get the token status to show the new scopes const tokenStatus = await authManager.getTokenStatus(); console.error(`Successfully acquired fresh token with additional scopes: ${scopes.join(', ')}`); return { content: [{ type: "text", text: JSON.stringify({ message: "Successfully acquired additional Microsoft Graph permissions with fresh authentication", requestedScopes: scopes, tokenStatus: tokenStatus, note: "A fresh sign-in was performed to ensure the new permissions are properly granted", timestamp: new Date().toISOString() }, null, 2) }], }; } catch (error) { logger.error("Error requesting additional Graph permissions:", error); return { content: [{ type: "text", text: `Error requesting additional permissions: ${error.message}` }], isError: true }; } }); // =========================================== // MINERVA HYBRID TOOLS (Graph + PowerShell) // =========================================== server.tool("getSharePointLists", "Retrieve SharePoint lists from a site using PnP PowerShell. Supports both certificate and token authentication.", { siteUrl: z.string().describe("SharePoint site URL (e.g., 'https://contoso.sharepoint.com/sites/mysite')"), includeHidden: z.boolean().optional().default(false).describe("Include hidden lists in results"), useGraph: z.boolean().optional().default(false).describe("Use Microsoft Graph API instead of PnP PowerShell") }, async ({ siteUrl, includeHidden, useGraph }) => { console.error(`Executing getSharePointLists: siteUrl=${siteUrl}, includeHidden=${includeHidden}, useGraph=${useGraph}`); try { if (useGraph) { // Mode Graph: utiliser l'API Graph pour récupérer les listes if (!graphClient) { throw new Error("Graph client not initialized"); } // Extraire le site ID depuis l'URL ou utiliser l'URL directement const siteInfo = siteUrl.match(/https:\/\/([^.]+)\.sharepoint\.com\/sites\/([^\/]+)/); if (!siteInfo) { throw new Error("Invalid SharePoint site URL format"); } const [, tenant, siteName] = siteInfo; const graphSiteId = `${tenant}.sharepoint.com:/sites/${siteName}`; const response = await graphClient .api(`/sites/${graphSiteId}/lists`) .select('id,displayName,description,createdDateTime,lastModifiedDateTime') .get(); const lists = response.value || []; return { content: [{ type: "text", text: JSON.stringify({ success: true, source: "Microsoft Graph", siteUrl, listCount: lists.length, lists: lists.map((list) => ({ id: list.id, title: list.displayName, description: list.description || "", created: list.createdDateTime, lastModified: list.lastModifiedDateTime, hidden: false, // Graph API doesn't provide hidden status in basic list query baseTemplate: "GenericList" // Default template for Graph API lists })), timestamp: new Date().toISOString() }, null, 2) }] }; } else { // Mode PnP PowerShell if (!powershellExecutor) { throw new Error("PowerShell executor not initialized"); } // Préparer les paramètres d'authentification const authParams = { SiteUrl: siteUrl, IncludeHidden: includeHidden || false }; // Ajouter les paramètres d'auth selon le mode configuré if (authManager) { const authMode = authManager.getAuthMode(); if (authMode === AuthMode.Certificate) { authParams.ClientId = process.env.CLIENT_ID || ""; authParams.TenantId = process.env.TENANT_ID || ""; authParams.CertificatePath = process.env.CERTIFICATE_PATH || ""; if (process.env.CERTIFICATE_PASSWORD) { authParams.CertificatePassword = process.env.CERTIFICATE_PASSWORD; } } else if (authMode === AuthMode.ClientProvidedToken) { // Pour ClientProvidedToken, essayer d'obtenir le token via le credential try { const credential = authManager.getAzureCredential(); if (credential) { const accessToken = await credential.getToken("https://graph.microsoft.com/.default"); if (accessToken && accessToken.token) { authParams.AccessToken = accessToken.token; } } } catch (error) { logger.error("Failed to get access token for PnP authentication:", error); } } } const result = await powershellExecutor.executeScript({ scriptPath: "Get-SharePointLists.ps1", parameters: authParams, timeout: 60000 // 1 minute }); if (!result.success) { throw new Error(`PowerShell execution failed: ${result.error}`); } return { content: [{ type: "text", text: JSON.stringify({ ...result.data, source: "PnP PowerShell", executionTime: result.executionTime }, null, 2) }] }; } } catch (error) { logger.error("Error in getSharePointLists:", error); return { content: [{ type: "text", text: JSON.stringify({ success: false, error: error.message, siteUrl, timestamp: new Date().toISOString() }, null, 2) }], isError: true }; } }); server.tool("getPnPSiteDesigns", "Retrieve SharePoint Site Designs from the tenant using PnP PowerShell. Requires tenant admin permissions.", { tenantUrl: z.string().describe("SharePoint admin center URL (e.g., 'https://contoso-admin.sharepoint.com')") }, async ({ tenantUrl }) => { console.error(`Executing getPnPSiteDesigns: tenantUrl=${tenantUrl}`); try { if (!powershellExecutor) { throw new Error("PowerShell executor not initialized"); } // Préparer les paramètres d'authentification const authParams = { TenantUrl: tenantUrl }; // Ajouter les paramètres d'auth selon le mode configuré if (authManager) { const authMode = authManager.getAuthMode(); if (authMode === AuthMode.Certificate) { authParams.ClientId = process.env.CLIENT_ID || ""; authParams.TenantId = process.env.TENANT_ID || ""; authParams.CertificatePath = process.env.CERTIFICATE_PATH || ""; if (process.env.CERTIFICATE_PASSWORD) { authParams.CertificatePassword = process.env.CERTIFICATE_PASSWORD; } } else if (authMode === AuthMode.ClientProvidedToken) { // Pour ClientProvidedToken, essayer d'obtenir le token via le credential try { const credential = authManager.getAzureCredential(); if (credential) { const accessToken = await credential.getToken("https://graph.microsoft.com/.default"); if (accessToken && accessToken.token) { authParams.AccessToken = accessToken.token; } } } catch (error) { logger.error("Failed to get access token for PnP authentication:", error); } } } const result = await powershellExecutor.executeScript({ scriptPath: "Get-SiteDesigns.ps1", parameters: authParams, timeout: 90000 // 1.5 minutes }); if (!result.success) { throw new Error(`PowerShell execution failed: ${result.error}`); } return { content: [{ type: "text", text: JSON.stringify({ ...result.data, source: "PnP PowerShell", executionTime: result.executionTime }, null, 2) }] }; } catch (error) { logger.error("Error in getPnPSiteDesigns:", error); return { content: [{ type: "text", text: JSON.stringify({ success: false, error: error.message, tenantUrl, timestamp: new Date().toISOString() }, null, 2) }], isError: true }; } }); server.tool("testPowerShellAvailability", "Test if PowerShell and PnP.PowerShell are available and working correctly.", {}, async () => { console.error("Testing PowerShell and PnP.PowerShell availability"); try { if (!powershellExecutor) { throw new Error("PowerShell executor not initialized"); } const availability = await powershellExecutor.testPowerShellAvailability(); return { content: [{ type: "text", text: JSON.stringify({ ...availability, timestamp: new Date().toISOString() }, null, 2) }] }; } catch (error) { logger.error("Error testing PowerShell availability:", error); return { content: [{