@houmak/minerva-mcp-server
Version:
Minerva Model Context Protocol (MCP) Server for Microsoft 365 and Azure integrations
936 lines (935 loc) • 76.7 kB
JavaScript
#!/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: [{