@north7/entraaware 0.0.1 → 0.0.2

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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/build/index.js +345 -71
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -36,7 +36,7 @@ Create a `.mcp.json` file in your VS Code workspace:
36
36
  "command": "npx",
37
37
  "args": [
38
38
  "-y",
39
- "@uniquk/entraaware@latest"
39
+ "@north7/entraaware@latest"
40
40
  ],
41
41
  "env": {
42
42
  "TENANT_ID": "your-tenant-id",
package/build/index.js CHANGED
@@ -5,8 +5,9 @@ import { z } from "zod";
5
5
  import { Client } from "@microsoft/microsoft-graph-client";
6
6
  import { ClientSecretCredential } from "@azure/identity";
7
7
  import { TokenCredentialAuthenticationProvider } from "@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials/index.js";
8
- // Initialize Graph Client
8
+ // Initialize clients
9
9
  let graphClient = null;
10
+ let azureCredential = null;
10
11
  // Create server instance
11
12
  const server = new McpServer({
12
13
  name: "EntraAware",
@@ -16,92 +17,365 @@ const server = new McpServer({
16
17
  tools: {},
17
18
  },
18
19
  });
19
- // Direct Microsoft Graph API access tool
20
- server.tool("askEntra", "Ask any question about your Microsoft Entra (Azure AD) tenant in natural language", {
21
- question: z.string().describe("Your natural language question about Microsoft 365 Entra (Azure AD)")
22
- }, async ({ question }) => {
20
+ // SHARED UTILITIES
21
+ function getCredentials() {
22
+ const tenantId = process.env.TENANT_ID;
23
+ const clientId = process.env.CLIENT_ID;
24
+ const clientSecret = process.env.CLIENT_SECRET;
25
+ if (!tenantId || !clientId || !clientSecret) {
26
+ throw new Error("Missing required environment variables: TENANT_ID, CLIENT_ID, or CLIENT_SECRET");
27
+ }
28
+ return { tenantId, clientId, clientSecret };
29
+ }
30
+ function getAzureCredential() {
31
+ if (!azureCredential) {
32
+ const { tenantId, clientId, clientSecret } = getCredentials();
33
+ azureCredential = new ClientSecretCredential(tenantId, clientId, clientSecret);
34
+ }
35
+ return azureCredential;
36
+ }
37
+ function formatApiResponse(apiType, method, path, result) {
38
+ return {
39
+ content: [
40
+ {
41
+ type: "text",
42
+ text: `${apiType} API Result (${method.toUpperCase()} ${path}):\n\n${JSON.stringify(result, null, 2)}`,
43
+ },
44
+ ],
45
+ };
46
+ }
47
+ function formatErrorResponse(err, apiType) {
48
+ const errorDetail = err instanceof Error
49
+ ? {
50
+ message: err.message,
51
+ name: err.name,
52
+ detail: apiType === 'Entra'
53
+ ? err.body ? JSON.stringify(err.body) : undefined
54
+ : err.response?.body || undefined,
55
+ status: err.statusCode || err.status
56
+ }
57
+ : String(err);
58
+ return {
59
+ content: [
60
+ {
61
+ type: "text",
62
+ text: `Error querying ${apiType} API: ${JSON.stringify(errorDetail, null, 2)}`,
63
+ },
64
+ ],
65
+ };
66
+ }
67
+ // MICROSOFT GRAPH API TOOL
68
+ server.tool("askEntra", "Direct access to Microsoft Graph API for accurate Entra (Azure AD) data", {
69
+ path: z.string().describe("The Graph API URL path (e.g. '/users/{id}/memberOf', '/directoryRoles')"),
70
+ method: z.enum(["get", "post", "put", "patch", "delete"]).default("get").describe("HTTP method to use"),
71
+ queryParams: z.record(z.string()).optional().describe("Query parameters for the request"),
72
+ body: z.record(z.string(), z.any()).optional().describe("Request body for POST/PUT/PATCH requests"),
73
+ apiVersion: z.enum(["v1.0", "beta"]).default("v1.0").describe("Microsoft Graph API version"),
74
+ fetchAllPages: z.boolean().optional().default(false).describe("Automatically fetch all pages of results"),
75
+ consistencyLevel: z.string().optional().describe("ConsistencyLevel header value (use 'eventual' for queries with $filter, $search, etc.)"),
76
+ // Shorthand params
77
+ select: z.string().optional().describe("Shorthand for $select query parameter"),
78
+ filter: z.string().optional().describe("Shorthand for $filter query parameter"),
79
+ expand: z.string().optional().describe("Shorthand for $expand query parameter"),
80
+ orderBy: z.string().optional().describe("Shorthand for $orderBy query parameter"),
81
+ top: z.number().optional().describe("Shorthand for $top query parameter"),
82
+ count: z.boolean().optional().describe("Shorthand for $count=true to include count of items"),
83
+ }, async ({ path, method, queryParams = {}, body, apiVersion, fetchAllPages, consistencyLevel, select, filter, expand, orderBy, top, count }) => {
23
84
  try {
24
- // Get or initialize Graph client
85
+ // Process shorthand query parameters
86
+ const processedParams = { ...queryParams };
87
+ if (select)
88
+ processedParams['$select'] = select;
89
+ if (filter)
90
+ processedParams['$filter'] = filter;
91
+ if (expand)
92
+ processedParams['$expand'] = expand;
93
+ if (orderBy)
94
+ processedParams['$orderby'] = orderBy;
95
+ if (top !== undefined)
96
+ processedParams['$top'] = top.toString();
97
+ if (count)
98
+ processedParams['$count'] = 'true';
99
+ // Initialize or get Graph client
25
100
  if (!graphClient) {
26
- graphClient = initGraphClient();
101
+ const credential = getAzureCredential();
102
+ const authProvider = new TokenCredentialAuthenticationProvider(credential, {
103
+ scopes: ["https://graph.microsoft.com/.default"],
104
+ });
105
+ graphClient = Client.initWithMiddleware({ authProvider });
106
+ }
107
+ // Build request with API path and version
108
+ let request = graphClient.api(path).version(apiVersion);
109
+ // Add query parameters
110
+ if (Object.keys(processedParams).length > 0) {
111
+ request = request.query(processedParams);
27
112
  }
28
- // Default path if we can't determine from the question
29
- let path = "/organization";
30
- // Extract any email addresses or GUIDs from the question
31
- const emailPattern = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/;
32
- const guidPattern = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i;
33
- const emailMatch = question.match(emailPattern);
34
- const guidMatch = question.match(guidPattern);
35
- // Check for specific keywords in the question to determine the right path
36
- const lowerQuestion = question.toLowerCase();
37
- if (lowerQuestion.includes("conditional access") || lowerQuestion.includes("policies")) {
38
- path = "/identity/conditionalAccess/policies";
113
+ // Add consistency level header if provided
114
+ if (consistencyLevel) {
115
+ request = request.header('ConsistencyLevel', consistencyLevel);
39
116
  }
40
- else if (lowerQuestion.includes("users") || lowerQuestion.includes("user")) {
41
- path = "/users";
42
- if (emailMatch) {
43
- path = `/users/${emailMatch[0]}`;
117
+ // Handle pagination for GET requests
118
+ let result;
119
+ if (method === 'get' && fetchAllPages) {
120
+ const firstPage = await request.get();
121
+ // If no pagination needed, return first page
122
+ if (!firstPage["@odata.nextLink"]) {
123
+ result = firstPage;
124
+ }
125
+ else {
126
+ // Collect all items from all pages
127
+ const allItems = [...(firstPage.value || [])];
128
+ let nextLink = firstPage["@odata.nextLink"];
129
+ while (nextLink) {
130
+ const nextPage = await graphClient.api(nextLink).get();
131
+ if (nextPage.value)
132
+ allItems.push(...nextPage.value);
133
+ nextLink = nextPage["@odata.nextLink"] || null;
134
+ }
135
+ result = {
136
+ "@odata.context": firstPage["@odata.context"],
137
+ value: allItems,
138
+ "@odata.count": firstPage["@odata.count"],
139
+ totalItemsFetched: allItems.length
140
+ };
44
141
  }
45
142
  }
46
- else if (lowerQuestion.includes("groups") || lowerQuestion.includes("group")) {
47
- path = "/groups";
143
+ else {
144
+ // Execute appropriate method
145
+ switch (method) {
146
+ case 'get':
147
+ result = await request.get();
148
+ break;
149
+ case 'post':
150
+ result = await request.post(body || {});
151
+ break;
152
+ case 'put':
153
+ result = await request.put(body || {});
154
+ break;
155
+ case 'patch':
156
+ result = await request.patch(body || {});
157
+ break;
158
+ case 'delete':
159
+ await request.delete();
160
+ result = { status: "Successfully deleted" };
161
+ break;
162
+ }
48
163
  }
49
- else if (lowerQuestion.includes("applications") || lowerQuestion.includes("apps")) {
50
- path = "/applications";
164
+ return formatApiResponse('Entra', method, path, result);
165
+ }
166
+ catch (err) {
167
+ return formatErrorResponse(err, 'Entra');
168
+ }
169
+ });
170
+ // AZURE RESOURCE MANAGEMENT API TOOL
171
+ server.tool("askAzure", "Direct access to Azure Resource Management API for managing Azure resources", {
172
+ path: z.string().describe("The Azure API path (e.g. '/subscriptions', '/resourceGroups/{name}')"),
173
+ method: z.enum(["get", "post", "put", "patch", "delete"]).default("get").describe("HTTP method to use"),
174
+ apiVersion: z.string().optional().describe("Azure API version - required for each Azure Resource Provider"),
175
+ subscriptionId: z.string().optional().describe("Azure Subscription ID (if not included in path)"),
176
+ body: z.record(z.string(), z.any()).optional().describe("Request body for POST/PUT/PATCH requests"),
177
+ queryParams: z.record(z.string()).optional().describe("Additional query parameters"),
178
+ fetchAllPages: z.boolean().optional().default(false).describe("Automatically fetch all pages of results"),
179
+ // Predefined operations
180
+ operation: z.enum([
181
+ "listResources", "listResourceProviders", "getResourceProvider",
182
+ "registerResourceProvider", "getResourceTypes", "getApiVersions",
183
+ "getLocations", "createResource", "deployTemplate", "deleteResource", "custom"
184
+ ]).optional().default("custom").describe("Predefined Azure operations"),
185
+ // Parameters for predefined operations
186
+ providerNamespace: z.string().optional().describe("Resource provider namespace (e.g. 'Microsoft.Compute')"),
187
+ resourceType: z.string().optional().describe("Resource type for specific operations"),
188
+ resourceGroupName: z.string().optional().describe("Resource group name for resource operations"),
189
+ resourceName: z.string().optional().describe("Resource name for resource operations"),
190
+ }, async ({ path, method, apiVersion, subscriptionId, body, queryParams = {}, fetchAllPages, operation = "custom", providerNamespace, resourceType, resourceGroupName, resourceName }) => {
191
+ try {
192
+ // Default API versions for common resource types
193
+ const defaultApiVersions = {
194
+ 'resources': '2021-04-01',
195
+ 'resourceGroups': '2021-04-01',
196
+ 'subscriptions': '2021-01-01',
197
+ 'providers': '2021-04-01',
198
+ 'deployments': '2021-04-01',
199
+ 'Microsoft.Compute/virtualMachines': '2023-03-01',
200
+ 'Microsoft.Storage/storageAccounts': '2023-01-01',
201
+ 'Microsoft.Network/virtualNetworks': '2023-04-01',
202
+ 'Microsoft.KeyVault/vaults': '2023-02-01'
203
+ };
204
+ // Handle predefined operations
205
+ if (operation !== "custom") {
206
+ const requiredSubscriptionId = !['listResourceProviders', 'getResourceProvider', 'registerResourceProvider'].includes(operation);
207
+ if (requiredSubscriptionId && !subscriptionId) {
208
+ throw new Error(`Operation '${operation}' requires a subscriptionId`);
209
+ }
210
+ switch (operation) {
211
+ case 'listResources':
212
+ path = resourceGroupName
213
+ ? `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/resources`
214
+ : `/subscriptions/${subscriptionId}/resources`;
215
+ apiVersion = apiVersion || defaultApiVersions['resources'];
216
+ break;
217
+ case 'listResourceProviders':
218
+ path = `/subscriptions/${subscriptionId}/providers`;
219
+ apiVersion = apiVersion || defaultApiVersions['providers'];
220
+ break;
221
+ case 'getResourceProvider':
222
+ if (!providerNamespace)
223
+ throw new Error("Operation 'getResourceProvider' requires a providerNamespace");
224
+ path = `/subscriptions/${subscriptionId}/providers/${providerNamespace}`;
225
+ apiVersion = apiVersion || defaultApiVersions['providers'];
226
+ break;
227
+ case 'registerResourceProvider':
228
+ if (!providerNamespace)
229
+ throw new Error("Operation 'registerResourceProvider' requires a providerNamespace");
230
+ path = `/subscriptions/${subscriptionId}/providers/${providerNamespace}/register`;
231
+ method = 'post';
232
+ apiVersion = apiVersion || defaultApiVersions['providers'];
233
+ break;
234
+ case 'getResourceTypes':
235
+ case 'getApiVersions':
236
+ case 'getLocations':
237
+ if (!providerNamespace)
238
+ throw new Error(`Operation '${operation}' requires providerNamespace`);
239
+ path = `/subscriptions/${subscriptionId}/providers/${providerNamespace}`;
240
+ apiVersion = apiVersion || defaultApiVersions['providers'];
241
+ break;
242
+ case 'createResource':
243
+ if (!resourceGroupName || !providerNamespace || !resourceType || !resourceName) {
244
+ throw new Error("Operation 'createResource' requires resourceGroupName, providerNamespace, resourceType, and resourceName");
245
+ }
246
+ if (!body)
247
+ throw new Error("Operation 'createResource' requires a request body");
248
+ path = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/${providerNamespace}/${resourceType}/${resourceName}`;
249
+ method = 'put';
250
+ const providerResourceKey = `${providerNamespace}/${resourceType}`;
251
+ apiVersion = apiVersion || defaultApiVersions[providerResourceKey] || '2021-04-01';
252
+ break;
253
+ case 'deployTemplate':
254
+ if (!resourceGroupName)
255
+ throw new Error("Operation 'deployTemplate' requires resourceGroupName");
256
+ if (!body?.properties?.template)
257
+ throw new Error("Operation 'deployTemplate' requires a template in the body");
258
+ const deploymentName = body.deploymentName || `deployment-${new Date().getTime()}`;
259
+ delete body.deploymentName;
260
+ path = `/subscriptions/${subscriptionId}/resourcegroups/${resourceGroupName}/providers/Microsoft.Resources/deployments/${deploymentName}`;
261
+ method = 'put';
262
+ apiVersion = apiVersion || defaultApiVersions['deployments'];
263
+ break;
264
+ case 'deleteResource':
265
+ if (!resourceGroupName || !providerNamespace || !resourceType || !resourceName) {
266
+ throw new Error("Operation 'deleteResource' requires resourceGroupName, providerNamespace, resourceType, and resourceName");
267
+ }
268
+ path = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/${providerNamespace}/${resourceType}/${resourceName}`;
269
+ method = 'delete';
270
+ const deleteResourceKey = `${providerNamespace}/${resourceType}`;
271
+ apiVersion = apiVersion || defaultApiVersions[deleteResourceKey] || '2021-04-01';
272
+ break;
273
+ }
51
274
  }
52
- else if (lowerQuestion.includes("roles") || lowerQuestion.includes("directory roles")) {
53
- path = "/directoryRoles";
275
+ // Ensure API version is provided
276
+ if (!apiVersion && !queryParams['api-version']) {
277
+ throw new Error("Azure Resource Management API requires an 'apiVersion' parameter");
54
278
  }
55
- // If a GUID was found and not already used in the path, append it
56
- if (guidMatch && !path.includes(guidMatch[0])) {
57
- path = `${path}/${guidMatch[0]}`;
279
+ // Get Azure credential
280
+ const credential = getAzureCredential();
281
+ // Construct the base URL and path
282
+ const baseUrl = "https://management.azure.com";
283
+ let fullPath = path;
284
+ if (subscriptionId && !path.includes('/subscriptions/')) {
285
+ fullPath = `/subscriptions/${subscriptionId}${path.startsWith('/') ? path : `/${path}`}`;
58
286
  }
59
- // Build and execute the request
60
- let request = graphClient.api(path);
61
- // Execute the request
62
- const result = await request.get();
63
- // Format the response
64
- return {
65
- content: [
66
- {
67
- type: "text",
68
- text: `📊 Entra API Result (${path}):\n\n${JSON.stringify(result, null, 2)}`,
69
- },
70
- ],
287
+ // Add api-version and other query parameters
288
+ const params = new URLSearchParams(queryParams);
289
+ if (apiVersion)
290
+ params.set('api-version', apiVersion);
291
+ // Get access token
292
+ const tokenResponse = await credential.getToken("https://management.azure.com/.default");
293
+ if (!tokenResponse?.token)
294
+ throw new Error("Failed to acquire Azure access token");
295
+ // Prepare request options
296
+ const headers = {
297
+ 'Authorization': `Bearer ${tokenResponse.token}`,
298
+ 'Content-Type': 'application/json'
299
+ };
300
+ const options = {
301
+ method: method.toUpperCase(),
302
+ headers
71
303
  };
304
+ if (['POST', 'PUT', 'PATCH'].includes(method.toUpperCase()) && body) {
305
+ options.body = JSON.stringify(body);
306
+ }
307
+ // Construct URL
308
+ const url = `${baseUrl}${fullPath}?${params.toString()}`;
309
+ // Execute request with pagination if needed
310
+ let result;
311
+ if (method === 'get' && fetchAllPages) {
312
+ // Fetch first page
313
+ const response = await fetch(url, options);
314
+ if (!response.ok) {
315
+ const errorText = await response.text();
316
+ throw new Error(`Azure API error: ${response.status} - ${errorText}`);
317
+ }
318
+ const firstPage = await response.json();
319
+ // If no pagination needed, return first page
320
+ if (!firstPage.nextLink) {
321
+ result = firstPage;
322
+ }
323
+ else {
324
+ // Collect all items from all pages
325
+ const allItems = [...(firstPage.value || [])];
326
+ let nextLink = firstPage.nextLink;
327
+ while (nextLink) {
328
+ const pageResponse = await fetch(nextLink, options);
329
+ if (!pageResponse.ok)
330
+ throw new Error(`Azure API pagination error: ${pageResponse.status}`);
331
+ const nextPage = await pageResponse.json();
332
+ if (nextPage.value)
333
+ allItems.push(...nextPage.value);
334
+ nextLink = nextPage.nextLink || null;
335
+ }
336
+ result = {
337
+ value: allItems,
338
+ count: allItems.length
339
+ };
340
+ }
341
+ }
342
+ else {
343
+ // Single page request
344
+ const response = await fetch(url, options);
345
+ if (!response.ok) {
346
+ const errorText = await response.text();
347
+ let errorDetail;
348
+ try {
349
+ errorDetail = JSON.parse(errorText);
350
+ }
351
+ catch {
352
+ errorDetail = errorText;
353
+ }
354
+ const error = new Error(`Azure API error: ${response.status}`);
355
+ error.status = response.status;
356
+ error.response = { body: errorDetail };
357
+ throw error;
358
+ }
359
+ const text = await response.text();
360
+ result = text ? JSON.parse(text) : { status: "Success" };
361
+ }
362
+ return formatApiResponse('Azure', method, path, result);
72
363
  }
73
364
  catch (err) {
74
- return {
75
- content: [
76
- {
77
- type: "text",
78
- text: `Error querying Entra API: ${err instanceof Error ? err.message : String(err)}`,
79
- },
80
- ],
81
- };
365
+ return formatErrorResponse(err, 'Azure');
82
366
  }
83
367
  });
84
- // Helper function to initialize the Microsoft Graph client
85
- function initGraphClient() {
86
- const tenantId = process.env.TENANT_ID;
87
- const clientId = process.env.CLIENT_ID;
88
- const clientSecret = process.env.CLIENT_SECRET;
89
- if (!tenantId || !clientId || !clientSecret) {
90
- throw new Error("Missing required Entra environment variables: TENANT_ID, CLIENT_ID, or CLIENT_SECRET");
91
- }
92
- const credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
93
- const authProvider = new TokenCredentialAuthenticationProvider(credential, {
94
- scopes: ["https://graph.microsoft.com/.default"],
95
- });
96
- return Client.initWithMiddleware({
97
- authProvider: authProvider,
98
- });
99
- }
100
- // Start the server with stdio transport
368
+ // SERVER STARTUP
101
369
  async function main() {
102
- const transport = new StdioServerTransport();
103
- await server.connect(transport);
104
- console.error("EntraAware MCP Server running on stdio");
370
+ try {
371
+ const transport = new StdioServerTransport();
372
+ await server.connect(transport);
373
+ console.error("EntraAware MCP Server running on stdio");
374
+ }
375
+ catch (error) {
376
+ console.error("Error starting server:", error);
377
+ process.exit(1);
378
+ }
105
379
  }
106
380
  main().catch((error) => {
107
381
  console.error("Fatal error in main():", error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north7/entraaware",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "type": "module",
5
5
  "main": "build/index.js",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "keywords": ["mcp-server", "entra", "azure-ad", "mcp", "microsoft-graph"],
17
17
  "author": "",
18
18
  "license": "ISC",
19
- "description": "MCP server for Microsoft Entra (Azure AD) integration",
19
+ "description": "MCP server for querying Entra and Azure",
20
20
  "files": [
21
21
  "build"
22
22
  ],