UNPKG

@north7/entraaware

Version:

MCP server for querying Microsoft Entra (Azure AD) and Azure Resource Management APIs

530 lines (529 loc) 26.2 kB
#!/usr/bin/env node /** * EntraAware MCP Server * * Portions of this code are adapted from the Lokka-Microsoft MCP Server (MIT License) * Original repository: https://github.com/merill/lokka */ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import { Client } from "@microsoft/microsoft-graph-client"; import { ClientSecretCredential, DefaultAzureCredential } from "@azure/identity"; import { TokenCredentialAuthenticationProvider } from "@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials/index.js"; // Initialize clients let graphClient = null; let azureCredential = null; // Create server instance const server = new McpServer({ name: "EntraAware", version: "0.0.7", capabilities: { resources: {}, tools: {}, }, }); // SHARED UTILITIES // The following credential handling utilities are adapted from Lokka-Microsoft function getCredentials() { const tenantId = process.env.TENANT_ID; const clientId = process.env.CLIENT_ID; const clientSecret = process.env.CLIENT_SECRET; if (!tenantId || !clientId || !clientSecret) { throw new Error("Missing required environment variables: TENANT_ID, CLIENT_ID, or CLIENT_SECRET"); } return { tenantId, clientId, clientSecret }; } // Helper to fetch the latest API version for a given resource provider and resource type async function getLatestApiVersion(providerNamespace, resourceType) { try { // Get Azure credential const credential = getAzureCredential(); // Get access token const tokenResponse = await credential.getToken("https://management.azure.com/.default"); if (!tokenResponse?.token) throw new Error("Failed to acquire Azure access token"); // Prepare request options const headers = { 'Authorization': `Bearer ${tokenResponse.token}`, 'Content-Type': 'application/json' }; // Make request to list provider details const url = `https://management.azure.com/providers/${providerNamespace}?api-version=2021-04-01`; const response = await fetch(url, { method: 'GET', headers }); if (!response.ok) { throw new Error(`Failed to fetch API versions: ${response.status}`); } const providerData = await response.json(); // If resourceType is specified, find that specific type if (resourceType) { const resourceTypeInfo = providerData.resourceTypes.find((rt) => rt.resourceType.toLowerCase() === resourceType.toLowerCase()); if (resourceTypeInfo && resourceTypeInfo.apiVersions && resourceTypeInfo.apiVersions.length > 0) { // Return the first (most recent) API version return resourceTypeInfo.apiVersions[0]; } } else { // Otherwise, just return a stable API version for the provider // Providers typically have their most recent versions first if (providerData.apiVersions && providerData.apiVersions.length > 0) { return providerData.apiVersions[0]; } } // If we get here, we couldn't find a good API version throw new Error(`Could not find API version for ${providerNamespace}${resourceType ? '/' + resourceType : ''}`); } catch (error) { console.error(`Error fetching API versions: ${error instanceof Error ? error.message : String(error)}`); // Return a default fallback version - you might want to customize this per provider return '2021-04-01'; } } // This Azure credential handling approach is similar to Lokka-Microsoft function getAzureCredential() { if (!azureCredential) { try { // Try DefaultAzureCredential which includes CLI credentials console.error("Attempting to use DefaultAzureCredential (will try Azure CLI if environment variables not set)"); azureCredential = new DefaultAzureCredential(); } catch (error) { // Fall back to ClientSecretCredential console.error(`DefaultAzureCredential failed: ${error instanceof Error ? error.message : String(error)}`); console.error("Falling back to ClientSecretCredential"); try { const { tenantId, clientId, clientSecret } = getCredentials(); azureCredential = new ClientSecretCredential(tenantId, clientId, clientSecret); } catch (secretError) { console.error(`ClientSecretCredential failed: ${secretError instanceof Error ? secretError.message : String(secretError)}`); throw new Error("Failed to initialize any Azure credential. Please ensure you are logged in with 'az login' or have set environment variables."); } } } return azureCredential; } // Response formatting utilities inspired by Lokka-Microsoft function formatApiResponse(apiType, method, path, result) { return { content: [ { type: "text", text: `${apiType} API Result (${method.toUpperCase()} ${path}):\n\n${JSON.stringify(result, null, 2)}`, }, ], }; } function formatErrorResponse(err, apiType) { const errorDetail = err instanceof Error ? { message: err.message, name: err.name, detail: apiType === 'Entra' ? err.body ? JSON.stringify(err.body) : undefined : err.response?.body || undefined, status: err.statusCode || err.status } : String(err); return { content: [ { type: "text", text: `Error querying ${apiType} API: ${JSON.stringify(errorDetail, null, 2)}`, }, ], }; } // Process OData parameters for Graph API function processODataParams({ queryParams = {}, select, filter, expand, orderBy, top, count }) { const processedParams = { ...queryParams }; if (select) processedParams['$select'] = select; if (filter) processedParams['$filter'] = filter; if (expand) processedParams['$expand'] = expand; if (orderBy) processedParams['$orderby'] = orderBy; if (top !== undefined) processedParams['$top'] = top.toString(); if (count) processedParams['$count'] = 'true'; return processedParams; } // MICROSOFT GRAPH API TOOL // This tool implementation is inspired by and adapted from Lokka-Microsoft's Graph API handling server.tool("askEntra", "Direct access to Microsoft Graph API for accurate Entra (Azure AD) data", { path: z.string().describe("The Graph API URL path (e.g. '/users/{id}/memberOf', '/directoryRoles')"), method: z.enum(["get", "post", "put", "patch", "delete"]).default("get").describe("HTTP method to use"), queryParams: z.record(z.string()).optional().describe("Query parameters for the request"), body: z.record(z.string(), z.any()).optional().describe("Request body for POST/PUT/PATCH requests"), apiVersion: z.enum(["v1.0", "beta"]).default("v1.0").describe("Microsoft Graph API version"), fetchAllPages: z.boolean().optional().default(false).describe("Automatically fetch all pages of results"), consistencyLevel: z.string().optional().describe("ConsistencyLevel header value (use 'eventual' for queries with $filter, $search, etc.)"), // Shorthand params select: z.string().optional().describe("Shorthand for $select query parameter"), filter: z.string().optional().describe("Shorthand for $filter query parameter"), expand: z.string().optional().describe("Shorthand for $expand query parameter"), orderBy: z.string().optional().describe("Shorthand for $orderBy query parameter"), top: z.number().optional().describe("Shorthand for $top query parameter"), count: z.boolean().optional().describe("Shorthand for $count=true to include count of items"), }, async ({ path, method, queryParams = {}, body, apiVersion, fetchAllPages, consistencyLevel, select, filter, expand, orderBy, top, count }) => { try { // Process shorthand query parameters const processedParams = processODataParams({ queryParams, select, filter, expand, orderBy, top, count }); // Initialize client on demand if (!graphClient) { const credential = getAzureCredential(); const authProvider = new TokenCredentialAuthenticationProvider(credential, { scopes: ["https://graph.microsoft.com/.default"], }); graphClient = Client.initWithMiddleware({ authProvider }); } // Build request with API path and version let request = graphClient.api(path).version(apiVersion); // Add query parameters if (Object.keys(processedParams).length > 0) { request = request.query(processedParams); } // Add consistency level header if provided if (consistencyLevel) { request = request.header('ConsistencyLevel', consistencyLevel); } // Handle pagination for GET requests let result; if (method === 'get' && fetchAllPages) { const firstPage = await request.get(); // If no pagination needed, return first page if (!firstPage["@odata.nextLink"]) { result = firstPage; } else { // Collect all items from all pages const allItems = [...(firstPage.value || [])]; let nextLink = firstPage["@odata.nextLink"]; while (nextLink) { const nextPage = await graphClient.api(nextLink).get(); if (nextPage.value) allItems.push(...nextPage.value); nextLink = nextPage["@odata.nextLink"] || null; } result = { "@odata.context": firstPage["@odata.context"], value: allItems, "@odata.count": firstPage["@odata.count"], totalItemsFetched: allItems.length }; } } else { // Execute appropriate method switch (method) { case 'get': result = await request.get(); break; case 'post': result = await request.post(body || {}); break; case 'put': result = await request.put(body || {}); break; case 'patch': result = await request.patch(body || {}); break; case 'delete': await request.delete(); result = { status: "Successfully deleted" }; break; } } return formatApiResponse('Entra', method, path, result); } catch (err) { return formatErrorResponse(err, 'Entra'); } }); // AZURE RESOURCE MANAGEMENT API TOOL // This tool implementation is inspired by and adapted from Lokka-Microsoft's Azure API handling server.tool("askAzure", "Direct access to Azure Resource Management API for managing Azure resources", { path: z.string().describe("The Azure API path (e.g. '/subscriptions', '/resourceGroups/{name}')"), method: z.enum(["get", "post", "put", "patch", "delete"]).default("get").describe("HTTP method to use"), apiVersion: z.string().optional().describe("Azure API version - required for each Azure Resource Provider"), subscriptionId: z.string().optional().describe("Azure Subscription ID (if not included in path)"), body: z.record(z.string(), z.any()).optional().describe("Request body for POST/PUT/PATCH requests"), queryParams: z.record(z.string()).optional().describe("Additional query parameters"), fetchAllPages: z.boolean().optional().default(false).describe("Automatically fetch all pages of results"), // Predefined operations operation: z.enum([ "listResources", "listResourceProviders", "getResourceProvider", "registerResourceProvider", "getResourceTypes", "getApiVersions", "getLocations", "createResource", "deployTemplate", "deleteResource", "custom" ]).optional().default("custom").describe("Predefined Azure operations"), // Parameters for predefined operations providerNamespace: z.string().optional().describe("Resource provider namespace (e.g. 'Microsoft.Compute')"), resourceType: z.string().optional().describe("Resource type for specific operations"), resourceGroupName: z.string().optional().describe("Resource group name for resource operations"), resourceName: z.string().optional().describe("Resource name for resource operations"), }, async ({ path, method, apiVersion, subscriptionId, body, queryParams = {}, fetchAllPages, operation = "custom", providerNamespace, resourceType, resourceGroupName, resourceName }) => { try { // Default API versions for common resource types const defaultApiVersions = { 'resources': '2021-04-01', 'resourceGroups': '2021-04-01', 'subscriptions': '2022-12-01', 'providers': '2021-04-01', 'deployments': '2021-04-01', 'Microsoft.Compute/virtualMachines': '2023-03-01', 'Microsoft.Storage/storageAccounts': '2023-01-01', 'Microsoft.Network/virtualNetworks': '2023-04-01', 'Microsoft.KeyVault/vaults': '2023-02-01', 'Microsoft.Billing/billingAccounts': '2024-04-01', 'Microsoft.CostManagement/query': '2023-03-01' }; // Set default API version for common paths if not provided if (!apiVersion && !queryParams['api-version']) { if (path === '/subscriptions') { apiVersion = defaultApiVersions['subscriptions']; // Default API version for listing subscriptions } } let extractedProviderNamespace = null; // Try to extract provider namespace from the path if not explicitly provided if (!providerNamespace && path.includes('/providers/')) { const match = path.match(/\/providers\/([^\/]+)/); if (match && match[1]) { extractedProviderNamespace = match[1]; } } // Handle predefined operations if (operation !== "custom") { const requiredSubscriptionId = !['listResourceProviders', 'getResourceProvider', 'registerResourceProvider'].includes(operation); if (requiredSubscriptionId && !subscriptionId) { throw new Error(`Operation '${operation}' requires a subscriptionId`); } switch (operation) { case 'listResources': path = resourceGroupName ? `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/resources` : `/subscriptions/${subscriptionId}/resources`; apiVersion = apiVersion || defaultApiVersions['resources']; break; case 'listResourceProviders': path = `/subscriptions/${subscriptionId}/providers`; apiVersion = apiVersion || defaultApiVersions['providers']; break; case 'getResourceProvider': if (!providerNamespace) throw new Error("Operation 'getResourceProvider' requires a providerNamespace"); path = `/subscriptions/${subscriptionId}/providers/${providerNamespace}`; apiVersion = apiVersion || defaultApiVersions['providers']; break; case 'registerResourceProvider': if (!providerNamespace) throw new Error("Operation 'registerResourceProvider' requires a providerNamespace"); path = `/subscriptions/${subscriptionId}/providers/${providerNamespace}/register`; method = 'post'; apiVersion = apiVersion || defaultApiVersions['providers']; break; case 'getResourceTypes': case 'getApiVersions': case 'getLocations': if (!providerNamespace) throw new Error(`Operation '${operation}' requires providerNamespace`); path = `/subscriptions/${subscriptionId}/providers/${providerNamespace}`; apiVersion = apiVersion || defaultApiVersions['providers']; break; case 'createResource': if (!resourceGroupName || !providerNamespace || !resourceType || !resourceName) { throw new Error("Operation 'createResource' requires resourceGroupName, providerNamespace, resourceType, and resourceName"); } if (!body) throw new Error("Operation 'createResource' requires a request body"); path = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/${providerNamespace}/${resourceType}/${resourceName}`; method = 'put'; const providerResourceKey = `${providerNamespace}/${resourceType}`; // Try to get the API version for this resource type if (!apiVersion) { // First check our default versions apiVersion = defaultApiVersions[providerResourceKey]; // If not found in defaults, try to fetch it dynamically if (!apiVersion && providerNamespace) { try { apiVersion = await getLatestApiVersion(providerNamespace, resourceType); } catch (error) { console.error(`Error fetching API version: ${error instanceof Error ? error.message : String(error)}`); apiVersion = '2021-04-01'; // Fall back to a safe default } } } break; case 'deployTemplate': if (!resourceGroupName) throw new Error("Operation 'deployTemplate' requires resourceGroupName"); if (!body?.properties?.template) throw new Error("Operation 'deployTemplate' requires a template in the body"); const deploymentName = body.deploymentName || `deployment-${new Date().getTime()}`; delete body.deploymentName; path = `/subscriptions/${subscriptionId}/resourcegroups/${resourceGroupName}/providers/Microsoft.Resources/deployments/${deploymentName}`; method = 'put'; apiVersion = apiVersion || defaultApiVersions['deployments']; break; case 'deleteResource': if (!resourceGroupName || !providerNamespace || !resourceType || !resourceName) { throw new Error("Operation 'deleteResource' requires resourceGroupName, providerNamespace, resourceType, and resourceName"); } path = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/${providerNamespace}/${resourceType}/${resourceName}`; method = 'delete'; const deleteResourceKey = `${providerNamespace}/${resourceType}`; // Try to get the API version for this resource type if (!apiVersion) { // First check our default versions apiVersion = defaultApiVersions[deleteResourceKey]; // If not found in defaults, try to fetch it dynamically if (!apiVersion && providerNamespace) { try { apiVersion = await getLatestApiVersion(providerNamespace, resourceType); } catch (error) { console.error(`Error fetching API version: ${error instanceof Error ? error.message : String(error)}`); apiVersion = '2021-04-01'; // Fall back to a safe default } } } break; } } else if (extractedProviderNamespace && !apiVersion && !queryParams['api-version']) { // For custom operations, try to get the API version based on the path try { // Try to extract resource type from path if possible let extractedResourceType; const resourceTypeMatch = path.match(/\/providers\/[^\/]+\/([^\/]+)\/?/); if (resourceTypeMatch && resourceTypeMatch[1]) { extractedResourceType = resourceTypeMatch[1]; } // Fetch the latest API version apiVersion = await getLatestApiVersion(extractedProviderNamespace, extractedResourceType); } catch (error) { console.error(`Error fetching API version from path: ${error instanceof Error ? error.message : String(error)}`); // Don't set a default here, let the error handling below catch it } } // Ensure API version is provided if (!apiVersion && !queryParams['api-version']) { throw new Error("Azure Resource Management API requires an 'apiVersion' parameter"); } // Get Azure credential const credential = getAzureCredential(); // Construct the base URL and path const baseUrl = "https://management.azure.com"; let fullPath = path; if (subscriptionId && !path.includes('/subscriptions/')) { fullPath = `/subscriptions/${subscriptionId}${path.startsWith('/') ? path : `/${path}`}`; } // Add api-version and other query parameters const params = new URLSearchParams(queryParams); if (apiVersion) params.set('api-version', apiVersion); // Get access token const tokenResponse = await credential.getToken("https://management.azure.com/.default"); if (!tokenResponse?.token) throw new Error("Failed to acquire Azure access token"); // Prepare request options const headers = { 'Authorization': `Bearer ${tokenResponse.token}`, 'Content-Type': 'application/json' }; const options = { method: method.toUpperCase(), headers }; if (['POST', 'PUT', 'PATCH'].includes(method.toUpperCase()) && body) { options.body = JSON.stringify(body); } // Construct URL const url = `${baseUrl}${fullPath}?${params.toString()}`; // Execute request with pagination if needed let result; if (method === 'get' && fetchAllPages) { // Fetch first page const response = await fetch(url, options); if (!response.ok) { const errorText = await response.text(); throw new Error(`Azure API error: ${response.status} - ${errorText}`); } const firstPage = await response.json(); // If no pagination needed, return first page if (!firstPage.nextLink) { result = firstPage; } else { // Collect all items from all pages const allItems = [...(firstPage.value || [])]; let nextLink = firstPage.nextLink; while (nextLink) { const pageResponse = await fetch(nextLink, options); if (!pageResponse.ok) throw new Error(`Azure API pagination error: ${pageResponse.status}`); const nextPage = await pageResponse.json(); if (nextPage.value) allItems.push(...nextPage.value); nextLink = nextPage.nextLink || null; } result = { value: allItems, count: allItems.length }; } } else { // Single page request const response = await fetch(url, options); if (!response.ok) { const errorText = await response.text(); let errorDetail; try { errorDetail = JSON.parse(errorText); } catch { errorDetail = errorText; } const error = new Error(`Azure API error: ${response.status}`); error.status = response.status; error.response = { body: errorDetail }; throw error; } const text = await response.text(); result = text ? JSON.parse(text) : { status: "Success" }; } return formatApiResponse('Azure', method, path, result); } catch (err) { return formatErrorResponse(err, 'Azure'); } }); // SERVER STARTUP async function main() { try { const transport = new StdioServerTransport(); await server.connect(transport); console.error("EntraAware MCP Server running on stdio"); } catch (error) { console.error("Error starting server:", error); process.exit(1); } } main().catch((error) => { console.error("Fatal error in main():", error); process.exit(1); });