@softeria/ms-365-mcp-server 0.80.0 → 0.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -273,6 +273,8 @@ Then add connection with URL `http://localhost:3000/mcp` and ID `ms-365`.
273
273
 
274
274
  ![Open WebUI MCP Connection](https://github.com/user-attachments/assets/dcab71dd-cf02-4bcb-b7db-5725d6be4064)
275
275
 
276
+ > **Running in Docker behind a reverse proxy?** Set `--public-url https://your-domain.com` so the OAuth authorize URL handed to the user's browser is reachable from outside the container network. See [docs/deployment.md](docs/deployment.md) for the full guide.
277
+
276
278
  ### Local Development
277
279
 
278
280
  For local development or testing:
@@ -449,7 +451,15 @@ npx @softeria/ms-365-mcp-server --list-presets # See all available presets
449
451
 
450
452
  Available presets: `mail`, `calendar`, `files`, `personal`, `work`, `excel`, `contacts`, `tasks`, `onenote`, `search`, `users`, `all`
451
453
 
452
- **Experimental:** `--discovery` starts with only 2 tools (`search-tools`, `execute-tool`) for minimal token usage.
454
+ ## Dynamic Tool Discovery
455
+
456
+ Instead of loading all 90+ tools upfront, use dynamic discovery so the LLM finds and loads tools only when it needs them:
457
+
458
+ ```bash
459
+ npx @softeria/ms-365-mcp-server --discovery
460
+ ```
461
+
462
+ Keeps the initial context small and cuts token usage, especially useful for long sessions or cost-sensitive setups (e.g. Open WebUI running against a paid API).
453
463
 
454
464
  ## CLI Options
455
465
 
@@ -481,7 +491,8 @@ When running as an MCP server, the following options can be used:
481
491
  --preset <names> Use preset tool categories (comma-separated). See "Tool Presets" section above
482
492
  --list-presets List all available presets and exit
483
493
  --toon (experimental) Enable TOON output format for 30-60% token reduction
484
- --discovery (experimental) Start with search-tools + execute-tool only
494
+ --discovery Dynamic tool discovery: loads tools on demand to reduce initial token usage (see "Dynamic Tool Discovery" above)
495
+ --public-url <url> Public base URL for OAuth when behind a reverse proxy (see Open WebUI section and docs/deployment.md)
485
496
  ```
486
497
 
487
498
  Environment variables:
@@ -1442,5 +1442,54 @@
1442
1442
  "toolName": "update-place",
1443
1443
  "workScopes": ["Place.ReadWrite.All"],
1444
1444
  "llmTip": "Updates properties of a place (room, room list, building, floor, section, desk, workspace). Body can include: displayName, phone, capacity, building, floorNumber, floorLabel, tags, audioDeviceName, videoDeviceName, displayDeviceName, isWheelChairAccessible. Use get-room or get-room-list to verify current values before updating."
1445
+ },
1446
+ {
1447
+ "pathPattern": "/me/insights/trending",
1448
+ "method": "get",
1449
+ "toolName": "list-trending-insights",
1450
+ "workScopes": ["Sites.Read.All"],
1451
+ "llmTip": "Lists documents trending around the current user. Each item has resourceVisualization (title, type, previewImageUrl, containerDisplayName), resourceReference (webUrl, id, type), and weight (relevance score). Use $filter=resourceVisualization/type eq 'PowerPoint' to filter by file type. Use $orderby=weight/value desc to sort by relevance."
1452
+ },
1453
+ {
1454
+ "pathPattern": "/me/events/{event-id}/cancel",
1455
+ "method": "post",
1456
+ "toolName": "cancel-calendar-event",
1457
+ "scopes": ["Calendars.ReadWrite"],
1458
+ "llmTip": "Cancels a meeting (organizer only) and sends a cancellation message to all attendees. Body: { Comment (optional string, custom message) }. Use this instead of delete-calendar-event when you want attendees to see 'Canceled' in their calendar. Attendees calling this get HTTP 400 — they should use decline-calendar-event instead."
1459
+ },
1460
+ {
1461
+ "pathPattern": "/me/events/{event-id}/forward",
1462
+ "method": "post",
1463
+ "toolName": "forward-calendar-event",
1464
+ "scopes": ["Calendars.ReadWrite"],
1465
+ "llmTip": "Forwards a meeting invitation to additional recipients. Body: { ToRecipients: [{ emailAddress: { address, name } }], Comment (optional) }. If the forwarder is an attendee (not organizer), the organizer is also notified and the new recipient is added to the organizer's attendee list."
1466
+ },
1467
+ {
1468
+ "pathPattern": "/me/events/{event-id}/snoozeReminder",
1469
+ "method": "post",
1470
+ "toolName": "snooze-calendar-event-reminder",
1471
+ "scopes": ["Calendars.ReadWrite"],
1472
+ "llmTip": "Postpones a triggered event reminder. Body: { NewReminderTime: { dateTime (ISO 8601), timeZone (IANA or Windows, e.g. 'Pacific Standard Time') } }. The reminder will re-fire at the new time."
1473
+ },
1474
+ {
1475
+ "pathPattern": "/me/events/{event-id}/dismissReminder",
1476
+ "method": "post",
1477
+ "toolName": "dismiss-calendar-event-reminder",
1478
+ "scopes": ["Calendars.ReadWrite"],
1479
+ "llmTip": "Dismisses a triggered event reminder so it won't re-fire. No request body required. Pair with list-calendar-events or get-schedule to find active reminders."
1480
+ },
1481
+ {
1482
+ "pathPattern": "/me/events/delta()",
1483
+ "method": "get",
1484
+ "toolName": "list-calendar-events-delta",
1485
+ "scopes": ["Calendars.Read"],
1486
+ "llmTip": "Incremental sync of events across the default calendar. First call returns all events plus @odata.deltaLink. Subsequent calls with that link return only additions/updates/removals. Use $select to limit fields. Deltas expire after ~30 days — start over if the server returns 410 Gone. For a time-bounded view with delta semantics, use list-calendar-view-delta instead."
1487
+ },
1488
+ {
1489
+ "pathPattern": "/me/calendarView/delta()",
1490
+ "method": "get",
1491
+ "toolName": "list-calendar-view-delta",
1492
+ "scopes": ["Calendars.Read"],
1493
+ "llmTip": "Incremental sync of events within a time window. Required query params on first call: startDateTime, endDateTime (ISO 8601). Returns events in the window plus @odata.deltaLink; subsequent calls with that link return only changes. Expands recurring events to individual occurrences (unlike list-calendar-events-delta which returns the series master). Use this for calendar UIs showing a week/month view."
1445
1494
  }
1446
1495
  ]
@@ -2455,6 +2455,53 @@ const decline_calendar_event_Body = z.object({
2455
2455
  SendResponse: z.boolean().nullable().default(false),
2456
2456
  Comment: z.string().nullable()
2457
2457
  }).partial().passthrough();
2458
+ const forward_calendar_event_Body = z.object({ ToRecipients: z.array(microsoft_graph_recipient), Comment: z.string().nullable() }).partial().passthrough();
2459
+ const snooze_calendar_event_reminder_Body = z.object({ NewReminderTime: microsoft_graph_dateTimeTimeZone }).partial().passthrough();
2460
+ const microsoft_graph_resourceReference = z.object({
2461
+ id: z.string().describe("The item's unique identifier.").nullish(),
2462
+ type: z.string().describe(
2463
+ "A string value that can be used to classify the item, such as 'microsoft.graph.driveItem'"
2464
+ ).nullish(),
2465
+ webUrl: z.string().describe("A URL leading to the referenced item.").nullish()
2466
+ }).passthrough();
2467
+ const microsoft_graph_resourceVisualization = z.object({
2468
+ containerDisplayName: z.string().describe(
2469
+ "A string describing where the item is stored. For example, the name of a SharePoint site or the user name identifying the owner of the OneDrive storing the item."
2470
+ ).nullish(),
2471
+ containerType: z.string().describe(
2472
+ "Can be used for filtering by the type of container in which the file is stored. Such as Site or OneDriveBusiness."
2473
+ ).nullish(),
2474
+ containerWebUrl: z.string().describe("A path leading to the folder in which the item is stored.").nullish(),
2475
+ mediaType: z.string().describe(
2476
+ "The item's media type. Can be used for filtering for a specific type of file based on supported IANA Media Mime Types. Not all Media Mime Types are supported."
2477
+ ).nullish(),
2478
+ previewImageUrl: z.string().describe("A URL leading to the preview image for the item.").nullish(),
2479
+ previewText: z.string().describe("A preview text for the item.").nullish(),
2480
+ title: z.string().describe("The item's title text.").nullish(),
2481
+ type: z.string().describe(
2482
+ "The item's media type. Can be used for filtering for a specific file based on a specific type. See the section Type property values for supported types."
2483
+ ).nullish()
2484
+ }).passthrough();
2485
+ const microsoft_graph_entity = z.object({ id: z.string().describe("The unique identifier for an entity. Read-only.").optional() }).passthrough();
2486
+ const microsoft_graph_trending = z.object({
2487
+ id: z.string().describe("The unique identifier for an entity. Read-only.").optional(),
2488
+ lastModifiedDateTime: z.string().regex(
2489
+ /^[0-9]{4,}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]{1,12})?(Z|[+-][0-9][0-9]:[0-9][0-9])$/
2490
+ ).datetime({ offset: true }).describe(
2491
+ "The Timestamp type represents date and time information using ISO 8601 format and is always in UTC time. For example, midnight UTC on Jan 1, 2014 is 2014-01-01T00:00:00Z"
2492
+ ).nullish(),
2493
+ resourceReference: microsoft_graph_resourceReference.optional(),
2494
+ resourceVisualization: microsoft_graph_resourceVisualization.optional(),
2495
+ weight: z.number().describe(
2496
+ "Value indicating how much the document is currently trending. The larger the number, the more the document is currently trending around the user (the more relevant it is). Returned documents are sorted by this value. [Simplified from 3 options]"
2497
+ ).nullish(),
2498
+ resource: microsoft_graph_entity.optional()
2499
+ }).passthrough();
2500
+ const microsoft_graph_trendingCollectionResponse = z.object({
2501
+ "@odata.count": z.number().int().nullable(),
2502
+ "@odata.nextLink": z.string().nullable(),
2503
+ value: z.array(microsoft_graph_trending)
2504
+ }).partial().passthrough();
2458
2505
  const microsoft_graph_giphyRatingType = z.enum(["strict", "moderate", "unknownFutureValue"]);
2459
2506
  const microsoft_graph_teamFunSettings = z.object({
2460
2507
  allowCustomMemes: z.boolean().describe("If set to true, enables users to include custom memes.").nullish(),
@@ -3918,7 +3965,6 @@ const microsoft_graph_searchAggregation = z.object({
3918
3965
  buckets: z.array(microsoft_graph_searchBucket).optional(),
3919
3966
  field: z.string().nullish()
3920
3967
  }).passthrough();
3921
- const microsoft_graph_entity = z.object({ id: z.string().describe("The unique identifier for an entity. Read-only.").optional() }).passthrough();
3922
3968
  const microsoft_graph_searchHit = z.object({
3923
3969
  contentSource: z.string().describe("The name of the content source that the externalItem is part of.").nullish(),
3924
3970
  hitId: z.string().describe(
@@ -4504,6 +4550,13 @@ const schemas = {
4504
4550
  microsoft_graph_driveCollectionResponse,
4505
4551
  accept_calendar_event_Body,
4506
4552
  decline_calendar_event_Body,
4553
+ forward_calendar_event_Body,
4554
+ snooze_calendar_event_reminder_Body,
4555
+ microsoft_graph_resourceReference,
4556
+ microsoft_graph_resourceVisualization,
4557
+ microsoft_graph_entity,
4558
+ microsoft_graph_trending,
4559
+ microsoft_graph_trendingCollectionResponse,
4507
4560
  microsoft_graph_giphyRatingType,
4508
4561
  microsoft_graph_teamFunSettings,
4509
4562
  microsoft_graph_teamGuestSettings,
@@ -4650,7 +4703,6 @@ const schemas = {
4650
4703
  search_query_Body,
4651
4704
  microsoft_graph_searchBucket,
4652
4705
  microsoft_graph_searchAggregation,
4653
- microsoft_graph_entity,
4654
4706
  microsoft_graph_searchHit,
4655
4707
  microsoft_graph_searchHitsContainer,
4656
4708
  microsoft_graph_alteredQueryToken,
@@ -6692,6 +6744,70 @@ or from some other calendar of the user.`,
6692
6744
  ],
6693
6745
  response: z.void()
6694
6746
  },
6747
+ {
6748
+ method: "get",
6749
+ path: "/me/calendarView/delta()",
6750
+ alias: "list-calendar-view-delta",
6751
+ description: `Get a set of event resources that have been added, deleted, or updated in a calendarView (a range of events defined by start and end dates) of the user's primary calendar. Typically, synchronizing events in a calendarView in a local store entails a round of multiple delta function calls. The initial call is a full synchronization, and every subsequent delta call in the same round gets the incremental changes (additions, deletions, or updates). This allows you to maintain and synchronize a local store of events in the specified calendarView, without having to fetch all the events of that calendar from the server every time.`,
6752
+ requestFormat: "json",
6753
+ parameters: [
6754
+ {
6755
+ name: "startDateTime",
6756
+ type: "Query",
6757
+ schema: z.string().describe(
6758
+ "The start date and time of the time range in the function, represented in ISO 8601 format. For example, 2019-11-08T20:00:00-08:00"
6759
+ )
6760
+ },
6761
+ {
6762
+ name: "endDateTime",
6763
+ type: "Query",
6764
+ schema: z.string().describe(
6765
+ "The end date and time of the time range in the function, represented in ISO 8601 format. For example, 2019-11-08T20:00:00-08:00"
6766
+ )
6767
+ },
6768
+ {
6769
+ name: "$top",
6770
+ type: "Query",
6771
+ schema: z.number().int().gte(0).describe("Show only the first n items").optional()
6772
+ },
6773
+ {
6774
+ name: "$skip",
6775
+ type: "Query",
6776
+ schema: z.number().int().gte(0).describe("Skip the first n items").optional()
6777
+ },
6778
+ {
6779
+ name: "$search",
6780
+ type: "Query",
6781
+ schema: z.string().describe("Search items by search phrases").optional()
6782
+ },
6783
+ {
6784
+ name: "$filter",
6785
+ type: "Query",
6786
+ schema: z.string().describe("Filter items by property values").optional()
6787
+ },
6788
+ {
6789
+ name: "$count",
6790
+ type: "Query",
6791
+ schema: z.boolean().describe("Include count of items").optional()
6792
+ },
6793
+ {
6794
+ name: "$select",
6795
+ type: "Query",
6796
+ schema: z.array(z.string()).describe("Select properties to be returned").optional()
6797
+ },
6798
+ {
6799
+ name: "$orderby",
6800
+ type: "Query",
6801
+ schema: z.array(z.string()).describe("Order items by property values").optional()
6802
+ },
6803
+ {
6804
+ name: "$expand",
6805
+ type: "Query",
6806
+ schema: z.array(z.string()).describe("Expand related entities").optional()
6807
+ }
6808
+ ],
6809
+ response: z.void()
6810
+ },
6695
6811
  {
6696
6812
  method: "get",
6697
6813
  path: "/me/chats",
@@ -7298,6 +7414,25 @@ open extensions or extended properties, and how to specify extended properties.`
7298
7414
  ],
7299
7415
  response: z.void()
7300
7416
  },
7417
+ {
7418
+ method: "post",
7419
+ path: "/me/events/:eventId/cancel",
7420
+ alias: "cancel-calendar-event",
7421
+ description: `This action allows the organizer of a meeting to send a cancellation message and cancel the event. The action moves the event to the Deleted Items folder. The organizer can also cancel an occurrence of a recurring meeting
7422
+ by providing the occurrence event ID. An attendee calling this action gets an error (HTTP 400 Bad Request), with the following
7423
+ error message: 'Your request can't be completed. You need to be an organizer to cancel a meeting.' This action differs from Delete in that Cancel is available to only the organizer, and lets
7424
+ the organizer send a custom message to the attendees about the cancellation.`,
7425
+ requestFormat: "json",
7426
+ parameters: [
7427
+ {
7428
+ name: "body",
7429
+ description: `Action parameters`,
7430
+ type: "Body",
7431
+ schema: z.object({ Comment: z.string().nullable() }).partial().passthrough()
7432
+ }
7433
+ ],
7434
+ response: z.void()
7435
+ },
7301
7436
  {
7302
7437
  method: "post",
7303
7438
  path: "/me/events/:eventId/decline",
@@ -7314,6 +7449,49 @@ open extensions or extended properties, and how to specify extended properties.`
7314
7449
  ],
7315
7450
  response: z.void()
7316
7451
  },
7452
+ {
7453
+ method: "post",
7454
+ path: "/me/events/:eventId/dismissReminder",
7455
+ alias: "dismiss-calendar-event-reminder",
7456
+ description: `Dismiss a reminder that has been triggered for an event in a user calendar.`,
7457
+ requestFormat: "json",
7458
+ response: z.void()
7459
+ },
7460
+ {
7461
+ method: "post",
7462
+ path: "/me/events/:eventId/forward",
7463
+ alias: "forward-calendar-event",
7464
+ description: `This action allows the organizer or attendee of a meeting event to forward the
7465
+ meeting request to a new recipient. If the meeting event is forwarded from an attendee's Microsoft 365 mailbox to another recipient, this action
7466
+ also sends a message to notify the organizer of the forwarding, and adds the recipient to the organizer's
7467
+ copy of the meeting event. This convenience is not available when forwarding from an Outlook.com account.`,
7468
+ requestFormat: "json",
7469
+ parameters: [
7470
+ {
7471
+ name: "body",
7472
+ description: `Action parameters`,
7473
+ type: "Body",
7474
+ schema: forward_calendar_event_Body
7475
+ }
7476
+ ],
7477
+ response: z.void()
7478
+ },
7479
+ {
7480
+ method: "post",
7481
+ path: "/me/events/:eventId/snoozeReminder",
7482
+ alias: "snooze-calendar-event-reminder",
7483
+ description: `Postpone a reminder for an event in a user calendar until a new time.`,
7484
+ requestFormat: "json",
7485
+ parameters: [
7486
+ {
7487
+ name: "body",
7488
+ description: `Action parameters`,
7489
+ type: "Body",
7490
+ schema: snooze_calendar_event_reminder_Body
7491
+ }
7492
+ ],
7493
+ response: z.void()
7494
+ },
7317
7495
  {
7318
7496
  method: "post",
7319
7497
  path: "/me/events/:eventId/tentativelyAccept",
@@ -7330,6 +7508,70 @@ open extensions or extended properties, and how to specify extended properties.`
7330
7508
  ],
7331
7509
  response: z.void()
7332
7510
  },
7511
+ {
7512
+ method: "get",
7513
+ path: "/me/events/delta()",
7514
+ alias: "list-calendar-events-delta",
7515
+ description: `Get a set of event resources that have been added, deleted, or updated in a calendarView (a range of events defined by start and end dates) of the user's primary calendar. Typically, synchronizing events in a calendarView in a local store entails a round of multiple delta function calls. The initial call is a full synchronization, and every subsequent delta call in the same round gets the incremental changes (additions, deletions, or updates). This allows you to maintain and synchronize a local store of events in the specified calendarView, without having to fetch all the events of that calendar from the server every time.`,
7516
+ requestFormat: "json",
7517
+ parameters: [
7518
+ {
7519
+ name: "startDateTime",
7520
+ type: "Query",
7521
+ schema: z.string().describe(
7522
+ "The start date and time of the time range in the function, represented in ISO 8601 format. For example, 2019-11-08T20:00:00-08:00"
7523
+ )
7524
+ },
7525
+ {
7526
+ name: "endDateTime",
7527
+ type: "Query",
7528
+ schema: z.string().describe(
7529
+ "The end date and time of the time range in the function, represented in ISO 8601 format. For example, 2019-11-08T20:00:00-08:00"
7530
+ )
7531
+ },
7532
+ {
7533
+ name: "$top",
7534
+ type: "Query",
7535
+ schema: z.number().int().gte(0).describe("Show only the first n items").optional()
7536
+ },
7537
+ {
7538
+ name: "$skip",
7539
+ type: "Query",
7540
+ schema: z.number().int().gte(0).describe("Skip the first n items").optional()
7541
+ },
7542
+ {
7543
+ name: "$search",
7544
+ type: "Query",
7545
+ schema: z.string().describe("Search items by search phrases").optional()
7546
+ },
7547
+ {
7548
+ name: "$filter",
7549
+ type: "Query",
7550
+ schema: z.string().describe("Filter items by property values").optional()
7551
+ },
7552
+ {
7553
+ name: "$count",
7554
+ type: "Query",
7555
+ schema: z.boolean().describe("Include count of items").optional()
7556
+ },
7557
+ {
7558
+ name: "$select",
7559
+ type: "Query",
7560
+ schema: z.array(z.string()).describe("Select properties to be returned").optional()
7561
+ },
7562
+ {
7563
+ name: "$orderby",
7564
+ type: "Query",
7565
+ schema: z.array(z.string()).describe("Order items by property values").optional()
7566
+ },
7567
+ {
7568
+ name: "$expand",
7569
+ type: "Query",
7570
+ schema: z.array(z.string()).describe("Expand related entities").optional()
7571
+ }
7572
+ ],
7573
+ response: z.void()
7574
+ },
7333
7575
  {
7334
7576
  method: "post",
7335
7577
  path: "/me/findMeetingTimes",
@@ -7347,6 +7589,56 @@ Based on this value, you can better adjust the parameters and call findMeetingTi
7347
7589
  ],
7348
7590
  response: z.void()
7349
7591
  },
7592
+ {
7593
+ method: "get",
7594
+ path: "/me/insights/trending",
7595
+ alias: "list-trending-insights",
7596
+ description: `Calculated insight that includes a list of documents trending around the user.`,
7597
+ requestFormat: "json",
7598
+ parameters: [
7599
+ {
7600
+ name: "$top",
7601
+ type: "Query",
7602
+ schema: z.number().int().gte(0).describe("Show only the first n items").optional()
7603
+ },
7604
+ {
7605
+ name: "$skip",
7606
+ type: "Query",
7607
+ schema: z.number().int().gte(0).describe("Skip the first n items").optional()
7608
+ },
7609
+ {
7610
+ name: "$search",
7611
+ type: "Query",
7612
+ schema: z.string().describe("Search items by search phrases").optional()
7613
+ },
7614
+ {
7615
+ name: "$filter",
7616
+ type: "Query",
7617
+ schema: z.string().describe("Filter items by property values").optional()
7618
+ },
7619
+ {
7620
+ name: "$count",
7621
+ type: "Query",
7622
+ schema: z.boolean().describe("Include count of items").optional()
7623
+ },
7624
+ {
7625
+ name: "$orderby",
7626
+ type: "Query",
7627
+ schema: z.array(z.string()).describe("Order items by property values").optional()
7628
+ },
7629
+ {
7630
+ name: "$select",
7631
+ type: "Query",
7632
+ schema: z.array(z.string()).describe("Select properties to be returned").optional()
7633
+ },
7634
+ {
7635
+ name: "$expand",
7636
+ type: "Query",
7637
+ schema: z.array(z.string()).describe("Expand related entities").optional()
7638
+ }
7639
+ ],
7640
+ response: z.void()
7641
+ },
7350
7642
  {
7351
7643
  method: "get",
7352
7644
  path: "/me/joinedTeams",
@@ -49,6 +49,8 @@ docker run -p 3000:3000 \
49
49
 
50
50
  ## Azure Container Apps
51
51
 
52
+ > **Turnkey Bicep example**: see [`examples/azure-container-apps/`](../examples/azure-container-apps/) for a complete Bicep template + PowerShell deploy script that provisions Log Analytics, UAMI, Key Vault (RBAC), Container Apps Environment and the Container App in one command.
53
+
52
54
  1. **Push the image** to Azure Container Registry:
53
55
 
54
56
  ```bash
@@ -0,0 +1,188 @@
1
+ # Azure Container Apps deployment example
2
+
3
+ > **Community-contributed example.** This is a turnkey starter — adapt it to your tenant, naming conventions, and security policies before running it in production. See [`docs/deployment.md`](../../docs/deployment.md) for the baseline deployment guide.
4
+
5
+ Deploys `ms-365-mcp-server` to Azure Container Apps in **Streamable HTTP** mode, with secrets stored in Azure Key Vault and fetched at startup by a user-assigned managed identity (UAMI). No credentials are written to environment variables.
6
+
7
+ ## Contents
8
+
9
+ - `main.bicep` — full infrastructure: Log Analytics, UAMI, Key Vault (RBAC), Container Apps Environment, Container App
10
+ - `deploy.ps1` — PowerShell 7 orchestrator (login, Resource Group, deployment, outputs)
11
+
12
+ ## Architecture
13
+
14
+ ```
15
+ MCP client (Claude Desktop / claude.ai / ...)
16
+ │ HTTPS + OAuth 2.0 (Dynamic Client Registration)
17
+
18
+ Container App (<baseName>-app)
19
+ - args: --http 3000 [--org-mode] [--read-only]
20
+ - scale 0-3 on concurrent HTTP requests
21
+ │ DefaultAzureCredential (UAMI)
22
+
23
+ Key Vault (<baseName>kv<suffix>)
24
+ - ms365-mcp-client-id
25
+ - ms365-mcp-tenant-id
26
+ - ms365-mcp-cloud-type
27
+ - ms365-mcp-client-secret (optional — confidential client)
28
+
29
+
30
+ Microsoft Graph API (per-user delegated token)
31
+ ```
32
+
33
+ ## Prerequisites
34
+
35
+ ### 1. Azure
36
+
37
+ - Subscription with **Contributor** + **User Access Administrator** roles on the target Resource Group (User Access Administrator is required for the UAMI → Key Vault RBAC assignment).
38
+ - [Azure CLI 2.60+](https://learn.microsoft.com/cli/azure/install-azure-cli).
39
+ - [PowerShell 7+](https://learn.microsoft.com/powershell/scripting/install/installing-powershell).
40
+
41
+ ### 2. Entra ID app registration
42
+
43
+ Create an app registration in your tenant:
44
+
45
+ - **Supported account types**: single tenant
46
+ - **Redirect URIs** (initial): `http://localhost:3000/oauth/callback` — update after the first deploy once you know the Container App FQDN
47
+ - **API permissions**: delegated scopes matching your run mode. Print the exact list with:
48
+ ```bash
49
+ npx @softeria/ms-365-mcp-server --list-permissions [--org-mode] [--read-only]
50
+ ```
51
+ Grant admin consent in Entra ID for all `*.All` scopes.
52
+ - **Authentication → Allow public client flows**: Yes (if you will not use a client secret)
53
+ - **Certificates & secrets** (optional): create a client secret for confidential-client mode
54
+
55
+ Collect `tenantId`, `clientId`, and (optionally) `clientSecret`.
56
+
57
+ ### 3. Container image
58
+
59
+ Default: `ghcr.io/softeria/ms-365-mcp-server:latest`.
60
+ Pin a version with `ghcr.io/softeria/ms-365-mcp-server:<tag>`, or push to your own Azure Container Registry and pass the reference via `-ContainerImage`.
61
+
62
+ ## Initial deployment
63
+
64
+ ```powershell
65
+ cd examples/azure-container-apps
66
+
67
+ # Your own object ID, so you can rotate Key Vault secrets later
68
+ $myOid = az ad signed-in-user show --query id -o tsv
69
+
70
+ ./deploy.ps1 `
71
+ -ResourceGroup 'rg-ms365mcp' `
72
+ -Location 'eastus' `
73
+ -BaseName 'ms365mcp' `
74
+ -TenantId '<TENANT_GUID>' `
75
+ -McpClientId '<APP_CLIENT_ID>' `
76
+ -KvAdminObjectIds @($myOid) `
77
+ -OrgMode $true
78
+ ```
79
+
80
+ The script prompts for the client secret (leave empty for a public client).
81
+
82
+ ### Dry-run
83
+
84
+ ```powershell
85
+ ./deploy.ps1 ... -WhatIf
86
+ ```
87
+
88
+ ## Post-deployment
89
+
90
+ ### 1. Update the redirect URI in Entra ID
91
+
92
+ The first deployment produces an FQDN such as `ms365mcp-app.<suffix>.<region>.azurecontainerapps.io`. Add `https://<fqdn>/oauth/callback` to your app registration → Authentication → Redirect URIs.
93
+
94
+ ### 2. Redeploy with `publicBaseUrl`
95
+
96
+ So the server advertises the correct public URL in OAuth metadata:
97
+
98
+ ```powershell
99
+ ./deploy.ps1 ... -PublicBaseUrl 'https://<fqdn>'
100
+ ```
101
+
102
+ ### 3. Smoke test
103
+
104
+ ```bash
105
+ # OAuth metadata (should return 200 with JSON)
106
+ curl https://<fqdn>/.well-known/oauth-authorization-server
107
+
108
+ # MCP endpoint (should return 401 without Authorization header)
109
+ curl -i https://<fqdn>/mcp
110
+ ```
111
+
112
+ ### 4. Connect an MCP client
113
+
114
+ See the [Client Configuration section](../../docs/deployment.md#client-configuration) in the main deployment guide.
115
+
116
+ ## Operations
117
+
118
+ ### Stream logs
119
+
120
+ ```bash
121
+ az containerapp logs show -n <baseName>-app -g <rg> --follow
122
+ ```
123
+
124
+ ### Force a new revision
125
+
126
+ ```bash
127
+ az containerapp update -n <baseName>-app -g <rg> --revision-suffix "manual$(date +%s)"
128
+ ```
129
+
130
+ ### Update the image
131
+
132
+ ```bash
133
+ az containerapp update -n <baseName>-app -g <rg> \
134
+ --image ghcr.io/softeria/ms-365-mcp-server:<new-tag>
135
+ ```
136
+
137
+ ### Rotate the client secret
138
+
139
+ ```bash
140
+ # 1. Create a new secret in Entra ID (Certificates & secrets)
141
+ # 2. Update Key Vault
142
+ az keyvault secret set --vault-name <kv-name> --name ms365-mcp-client-secret --value "<new-secret>"
143
+ # 3. Restart to pick up the new value
144
+ az containerapp update -n <baseName>-app -g <rg> --revision-suffix "rotate$(date +%s)"
145
+ ```
146
+
147
+ ### Pin minimum replicas (eliminate cold start)
148
+
149
+ ```bash
150
+ az containerapp update -n <baseName>-app -g <rg> --min-replicas 1 --max-replicas 5
151
+ ```
152
+
153
+ ## Cost (order of magnitude)
154
+
155
+ | Resource | Config | Monthly (idle) |
156
+ | ------------- | ---------------------- | ------------------------------------------ |
157
+ | Container App | Consumption, scale 0-3 | ~$0 with scale-to-zero, ~$15-25 with min=1 |
158
+ | Log Analytics | 30-day retention | ~$2-5 depending on log volume |
159
+ | Key Vault | Standard, low ops | <$1 |
160
+ | UAMI | — | free |
161
+
162
+ Regional pricing varies — consult the [Azure pricing calculator](https://azure.microsoft.com/pricing/calculator/) for your region. Scale-to-zero introduces a ~2–5 s cold start on the first request after idle.
163
+
164
+ ## Security notes
165
+
166
+ - **Secrets**: never in environment variables — always via Key Vault + UAMI
167
+ - **Ingress**: external HTTPS only. To restrict by IP, add `ipSecurityRestrictions` under `configuration.ingress`
168
+ - **RBAC**: UAMI receives only `Key Vault Secrets User` (read). Rotation is done by the admin principals listed in `-KvAdminObjectIds`
169
+ - **Purge protection** is enabled on Key Vault (90-day soft-delete) — accidental deletions are recoverable
170
+ - **CORS**: defaults to `http://localhost:3000`. Set `-CorsOrigin 'https://claude.ai'` (or your client URL) for hosted clients
171
+
172
+ ## Cleanup
173
+
174
+ ```bash
175
+ az group delete -n <rg> --yes --no-wait
176
+ # Key Vault remains in soft-delete for 30 days. To purge immediately:
177
+ az keyvault purge --name <kv-name>
178
+ ```
179
+
180
+ ## Troubleshooting
181
+
182
+ | Symptom | Likely cause | Fix |
183
+ | ---------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------- |
184
+ | Container restarts in a loop | UAMI can't read Key Vault secrets | Wait ~5 min for role propagation, then check `az role assignment list --assignee <uami-principal-id>` |
185
+ | 401 on `/mcp` even with a valid token | Wrong `tenantId`/`clientId` in Key Vault | `az keyvault secret show --vault-name <kv> --name ms365-mcp-client-id` |
186
+ | OAuth fails with `redirect_uri mismatch` | Redirect URI in Entra ID not updated | Add `https://<fqdn>/oauth/callback` in the app registration |
187
+ | Graph returns 403 | Missing scope / admin consent not granted | Re-run `--list-permissions` and grant admin consent |
188
+ | Cold start > 10 s | Heavy startup work in custom image | Verify the image does not run `npm install` in its ENTRYPOINT |
@@ -0,0 +1,170 @@
1
+ #Requires -Version 7.0
2
+ <#
3
+ .SYNOPSIS
4
+ Deploys ms-365-mcp-server to Azure Container Apps using the colocated Bicep template.
5
+
6
+ .DESCRIPTION
7
+ Creates (or updates) the target Resource Group and runs the Bicep deployment.
8
+ The Entra ID client secret is prompted interactively and stored in Key Vault.
9
+
10
+ .EXAMPLE
11
+ ./deploy.ps1 -ResourceGroup rg-ms365mcp -BaseName ms365mcp `
12
+ -TenantId "<tenant-guid>" -McpClientId "<app-guid>" `
13
+ -KvAdminObjectIds @("<your-object-id>")
14
+
15
+ .NOTES
16
+ Requirements:
17
+ - Azure CLI 2.60+ (az)
18
+ - PowerShell 7+
19
+ - Azure subscription with Contributor + User Access Administrator roles
20
+ (User Access Administrator is needed for the UAMI -> Key Vault RBAC assignment)
21
+ - Entra ID app registration created beforehand, with a redirect URI matching
22
+ the Container App FQDN (update it after the first deployment).
23
+ #>
24
+ [CmdletBinding()]
25
+ param(
26
+ [Parameter(Mandatory)][string]$ResourceGroup,
27
+
28
+ [Parameter(Mandatory)]
29
+ [ValidatePattern('^[a-z0-9]{3,20}$')]
30
+ [string]$BaseName,
31
+
32
+ [Parameter(Mandatory)][string]$TenantId,
33
+ [Parameter(Mandatory)][string]$McpClientId,
34
+
35
+ [string]$Location = 'eastus',
36
+ [string]$ContainerImage = 'ghcr.io/softeria/ms-365-mcp-server:latest',
37
+ [ValidateSet('global', 'gcc-high', 'dod', 'china')]
38
+ [string]$CloudType = 'global',
39
+ [string]$CorsOrigin = 'http://localhost:3000',
40
+ [string]$PublicBaseUrl = '',
41
+ [string[]]$KvAdminObjectIds = @(),
42
+ [bool]$OrgMode = $true,
43
+ [bool]$ReadOnly = $false,
44
+ [int]$MinReplicas = 0,
45
+ [int]$MaxReplicas = 3,
46
+ [switch]$SkipLogin,
47
+ [switch]$WhatIf
48
+ )
49
+
50
+ $ErrorActionPreference = 'Stop'
51
+ $bicepFile = Join-Path $PSScriptRoot 'main.bicep'
52
+
53
+ if (-not (Test-Path $bicepFile)) {
54
+ throw "Bicep file not found: $bicepFile"
55
+ }
56
+
57
+ # --- Prerequisites ---
58
+ if (-not (Get-Command az -ErrorAction SilentlyContinue)) {
59
+ throw 'Azure CLI not found. Install via: https://learn.microsoft.com/cli/azure/install-azure-cli'
60
+ }
61
+
62
+ Write-Host 'Checking/installing Bicep CLI...' -ForegroundColor DarkGray
63
+ az bicep install 2>$null | Out-Null
64
+ az bicep upgrade 2>$null | Out-Null
65
+
66
+ # --- Authentication ---
67
+ if (-not $SkipLogin) {
68
+ $acct = az account show --only-show-errors 2>$null | ConvertFrom-Json
69
+ if (-not $acct) {
70
+ Write-Host 'No active Azure session — launching az login...' -ForegroundColor Yellow
71
+ az login --tenant $TenantId --only-show-errors | Out-Null
72
+ $acct = az account show | ConvertFrom-Json
73
+ }
74
+ Write-Host "Active subscription : $($acct.name) ($($acct.id))" -ForegroundColor Green
75
+ Write-Host "Tenant : $($acct.tenantId)" -ForegroundColor Green
76
+ if ($acct.tenantId -ne $TenantId) {
77
+ Write-Warning "Active tenant ($($acct.tenantId)) does not match target tenant ($TenantId)."
78
+ $confirm = Read-Host 'Continue anyway? [y/N]'
79
+ if ($confirm -ne 'y') { throw 'Cancelled by user.' }
80
+ }
81
+ }
82
+
83
+ # --- Client secret (interactive, SecureString) ---
84
+ $secretSecure = Read-Host 'Entra ID client secret (leave empty for public client)' -AsSecureString
85
+ $secretPlain = ''
86
+ if ($secretSecure.Length -gt 0) {
87
+ $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secretSecure)
88
+ try {
89
+ $secretPlain = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
90
+ } finally {
91
+ [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
92
+ }
93
+ }
94
+
95
+ # --- Resource Group ---
96
+ $rg = az group show -n $ResourceGroup --only-show-errors 2>$null | ConvertFrom-Json
97
+ if (-not $rg) {
98
+ Write-Host "Creating Resource Group '$ResourceGroup' in '$Location'..." -ForegroundColor Cyan
99
+ az group create -n $ResourceGroup -l $Location --tags project=ms-365-mcp-server managedBy=bicep -o none
100
+ } else {
101
+ Write-Host "Resource Group '$ResourceGroup' exists ($($rg.location))" -ForegroundColor Green
102
+ }
103
+
104
+ # --- Deployment parameters ---
105
+ $deployName = "ms365mcp-$(Get-Date -Format 'yyyyMMddHHmmss')"
106
+ $params = @{
107
+ baseName = @{ value = $BaseName }
108
+ tenantId = @{ value = $TenantId }
109
+ mcpClientId = @{ value = $McpClientId }
110
+ mcpClientSecret = @{ value = $secretPlain }
111
+ cloudType = @{ value = $CloudType }
112
+ containerImage = @{ value = $ContainerImage }
113
+ corsOrigin = @{ value = $CorsOrigin }
114
+ publicBaseUrl = @{ value = $PublicBaseUrl }
115
+ orgMode = @{ value = $OrgMode }
116
+ readOnly = @{ value = $ReadOnly }
117
+ minReplicas = @{ value = $MinReplicas }
118
+ maxReplicas = @{ value = $MaxReplicas }
119
+ kvAdminObjectIds = @{ value = $KvAdminObjectIds }
120
+ }
121
+ $paramsFile = New-TemporaryFile
122
+ try {
123
+ @{
124
+ '$schema' = 'https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#'
125
+ contentVersion = '1.0.0.0'
126
+ parameters = $params
127
+ } | ConvertTo-Json -Depth 10 | Set-Content -Path $paramsFile.FullName
128
+
129
+ # --- Validation (what-if) ---
130
+ if ($WhatIf) {
131
+ Write-Host '--- what-if ---' -ForegroundColor Magenta
132
+ az deployment group what-if `
133
+ -g $ResourceGroup `
134
+ -n $deployName `
135
+ -f $bicepFile `
136
+ -p "@$($paramsFile.FullName)"
137
+ return
138
+ }
139
+
140
+ # --- Deployment ---
141
+ Write-Host "Starting deployment '$deployName'..." -ForegroundColor Cyan
142
+ $outputs = az deployment group create `
143
+ -g $ResourceGroup `
144
+ -n $deployName `
145
+ -f $bicepFile `
146
+ -p "@$($paramsFile.FullName)" `
147
+ --query properties.outputs `
148
+ -o json | ConvertFrom-Json
149
+
150
+ # --- Summary ---
151
+ Write-Host ''
152
+ Write-Host 'Deployment succeeded.' -ForegroundColor Green
153
+ Write-Host " Public URL : $($outputs.appUrl.value)" -ForegroundColor Yellow
154
+ Write-Host " Key Vault URI : $($outputs.keyVaultUri.value)"
155
+ Write-Host " Key Vault name : $($outputs.keyVaultName.value)"
156
+ Write-Host " Managed identity : $($outputs.uamiName.value)"
157
+ Write-Host " UAMI clientId : $($outputs.uamiClientId.value)"
158
+ Write-Host " Log Analytics : $($outputs.logAnalyticsName.value)"
159
+ Write-Host ''
160
+ Write-Host 'Next steps:' -ForegroundColor Cyan
161
+ Write-Host " 1. Add '$($outputs.appUrl.value)/oauth/callback' as a redirect URI in your Entra ID app."
162
+ Write-Host " 2. Re-run with -PublicBaseUrl '$($outputs.appUrl.value)' so OAuth metadata returns the public URL."
163
+ Write-Host " 3. Test : curl $($outputs.appUrl.value)/.well-known/oauth-authorization-server"
164
+ Write-Host " 4. Logs : az containerapp logs show -n '$BaseName-app' -g '$ResourceGroup' --follow"
165
+ }
166
+ finally {
167
+ Remove-Item $paramsFile.FullName -ErrorAction SilentlyContinue
168
+ $secretPlain = $null
169
+ [GC]::Collect()
170
+ }
@@ -0,0 +1,252 @@
1
+ // ms-365-mcp-server — Azure Container Apps deployment (community example)
2
+ //
3
+ // Deploys a turnkey stack:
4
+ // - Log Analytics workspace
5
+ // - User-Assigned Managed Identity (UAMI)
6
+ // - Key Vault (RBAC) with MCP secrets
7
+ // - Container Apps Environment (Consumption)
8
+ // - Container App running the server in Streamable HTTP mode
9
+ //
10
+ // The container uses DefaultAzureCredential (via the UAMI) to read secrets
11
+ // from Key Vault at startup — credentials never appear in environment variables.
12
+
13
+ @description('Base name prefix for all resources (lowercase, 3-20 chars, e.g. ms365mcpprod)')
14
+ @minLength(3)
15
+ @maxLength(20)
16
+ param baseName string
17
+
18
+ @description('Azure region. Defaults to the resource group location.')
19
+ param location string = resourceGroup().location
20
+
21
+ @description('Container image reference (public image by default).')
22
+ param containerImage string = 'ghcr.io/softeria/ms-365-mcp-server:latest'
23
+
24
+ @description('Entra ID tenant ID (GUID).')
25
+ param tenantId string
26
+
27
+ @description('Entra ID app registration clientId used by the MCP server.')
28
+ param mcpClientId string
29
+
30
+ @secure()
31
+ @description('Client secret for the MCP app registration. Leave empty for a public client.')
32
+ param mcpClientSecret string = ''
33
+
34
+ @description('Microsoft cloud type: global, gcc-high, dod, china. Defaults to global.')
35
+ @allowed([
36
+ 'global'
37
+ 'gcc-high'
38
+ 'dod'
39
+ 'china'
40
+ ])
41
+ param cloudType string = 'global'
42
+
43
+ @description('CORS origin for the MCP HTTP endpoint. Set to your client URL (e.g. https://claude.ai).')
44
+ param corsOrigin string = 'http://localhost:3000'
45
+
46
+ @description('Public base URL advertised in OAuth metadata. Derived from ingress FQDN after first deploy — leave empty initially, then redeploy with the assigned URL.')
47
+ param publicBaseUrl string = ''
48
+
49
+ @description('Object IDs of users/groups granted Key Vault Administrator role (for rotating secrets).')
50
+ param kvAdminObjectIds array = []
51
+
52
+ @description('Enable --org-mode (Teams, SharePoint, Groups, etc.). Defaults to true.')
53
+ param orgMode bool = true
54
+
55
+ @description('Enable --read-only flag (disables write operations).')
56
+ param readOnly bool = false
57
+
58
+ @description('Min replicas for autoscale. Set to 0 for scale-to-zero (cold start ~2-5s).')
59
+ param minReplicas int = 0
60
+
61
+ @description('Max replicas for autoscale.')
62
+ param maxReplicas int = 3
63
+
64
+ @description('Tags applied to all resources.')
65
+ param tags object = {
66
+ project: 'ms-365-mcp-server'
67
+ managedBy: 'bicep'
68
+ }
69
+
70
+ // ---------- Derived ----------
71
+ var suffix = toLower(substring(uniqueString(resourceGroup().id), 0, 5))
72
+ var logName = '${baseName}-log-${suffix}'
73
+ var uamiName = '${baseName}-uami'
74
+ var kvName = take('${baseName}kv${suffix}', 24)
75
+ var caeName = '${baseName}-cae'
76
+ var appName = '${baseName}-app'
77
+
78
+ // Built-in role definition IDs
79
+ var roleKvSecretsUser = '4633458b-17de-408a-b874-0445c86b69e6'
80
+ var roleKvAdministrator = '00482a5a-887f-4fb3-b363-3b7fe8e74483'
81
+
82
+ // ---------- Log Analytics ----------
83
+ resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
84
+ name: logName
85
+ location: location
86
+ tags: tags
87
+ properties: {
88
+ sku: { name: 'PerGB2018' }
89
+ retentionInDays: 30
90
+ features: { enableLogAccessUsingOnlyResourcePermissions: true }
91
+ }
92
+ }
93
+
94
+ // ---------- User-Assigned Managed Identity ----------
95
+ resource uami 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-07-31-preview' = {
96
+ name: uamiName
97
+ location: location
98
+ tags: tags
99
+ }
100
+
101
+ // ---------- Key Vault (RBAC) ----------
102
+ resource kv 'Microsoft.KeyVault/vaults@2024-04-01-preview' = {
103
+ name: kvName
104
+ location: location
105
+ tags: tags
106
+ properties: {
107
+ sku: { family: 'A', name: 'standard' }
108
+ tenantId: tenantId
109
+ enableRbacAuthorization: true
110
+ enableSoftDelete: true
111
+ softDeleteRetentionInDays: 30
112
+ enablePurgeProtection: true
113
+ publicNetworkAccess: 'Enabled'
114
+ networkAcls: {
115
+ bypass: 'AzureServices'
116
+ defaultAction: 'Allow'
117
+ }
118
+ }
119
+ }
120
+
121
+ resource secretClientId 'Microsoft.KeyVault/vaults/secrets@2024-04-01-preview' = {
122
+ parent: kv
123
+ name: 'ms365-mcp-client-id'
124
+ properties: { value: mcpClientId }
125
+ }
126
+
127
+ resource secretTenantId 'Microsoft.KeyVault/vaults/secrets@2024-04-01-preview' = {
128
+ parent: kv
129
+ name: 'ms365-mcp-tenant-id'
130
+ properties: { value: tenantId }
131
+ }
132
+
133
+ resource secretCloudType 'Microsoft.KeyVault/vaults/secrets@2024-04-01-preview' = {
134
+ parent: kv
135
+ name: 'ms365-mcp-cloud-type'
136
+ properties: { value: cloudType }
137
+ }
138
+
139
+ resource secretClientSecret 'Microsoft.KeyVault/vaults/secrets@2024-04-01-preview' = if (!empty(mcpClientSecret)) {
140
+ parent: kv
141
+ name: 'ms365-mcp-client-secret'
142
+ properties: { value: mcpClientSecret }
143
+ }
144
+
145
+ // Grant UAMI the Key Vault Secrets User role on the vault
146
+ resource roleUami 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
147
+ scope: kv
148
+ name: guid(kv.id, uami.id, roleKvSecretsUser)
149
+ properties: {
150
+ principalId: uami.properties.principalId
151
+ principalType: 'ServicePrincipal'
152
+ roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleKvSecretsUser)
153
+ }
154
+ }
155
+
156
+ // Grant human admins the Key Vault Administrator role (to rotate secrets)
157
+ resource roleAdmins 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for oid in kvAdminObjectIds: {
158
+ scope: kv
159
+ name: guid(kv.id, oid, roleKvAdministrator)
160
+ properties: {
161
+ principalId: oid
162
+ principalType: 'User'
163
+ roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleKvAdministrator)
164
+ }
165
+ }]
166
+
167
+ // ---------- Container Apps Environment ----------
168
+ resource cae 'Microsoft.App/managedEnvironments@2024-03-01' = {
169
+ name: caeName
170
+ location: location
171
+ tags: tags
172
+ properties: {
173
+ appLogsConfiguration: {
174
+ destination: 'log-analytics'
175
+ logAnalyticsConfiguration: {
176
+ customerId: logAnalytics.properties.customerId
177
+ sharedKey: logAnalytics.listKeys().primarySharedKey
178
+ }
179
+ }
180
+ workloadProfiles: [
181
+ { name: 'Consumption', workloadProfileType: 'Consumption' }
182
+ ]
183
+ }
184
+ }
185
+
186
+ // ---------- Container App ----------
187
+ var containerArgs = concat(['--http', '3000'], orgMode ? ['--org-mode'] : [], readOnly ? ['--read-only'] : [])
188
+
189
+ resource app 'Microsoft.App/containerApps@2024-03-01' = {
190
+ name: appName
191
+ location: location
192
+ tags: tags
193
+ identity: {
194
+ type: 'UserAssigned'
195
+ userAssignedIdentities: {
196
+ '${uami.id}': {}
197
+ }
198
+ }
199
+ properties: {
200
+ managedEnvironmentId: cae.id
201
+ configuration: {
202
+ activeRevisionsMode: 'Single'
203
+ ingress: {
204
+ external: true
205
+ targetPort: 3000
206
+ transport: 'auto'
207
+ allowInsecure: false
208
+ traffic: [ { latestRevision: true, weight: 100 } ]
209
+ }
210
+ }
211
+ template: {
212
+ containers: [
213
+ {
214
+ name: 'mcp'
215
+ image: containerImage
216
+ args: containerArgs
217
+ resources: {
218
+ cpu: json('0.5')
219
+ memory: '1.0Gi'
220
+ }
221
+ env: [
222
+ { name: 'MS365_MCP_KEYVAULT_URL', value: kv.properties.vaultUri }
223
+ { name: 'MS365_MCP_CORS_ORIGIN', value: corsOrigin }
224
+ { name: 'MS365_MCP_BASE_URL', value: empty(publicBaseUrl) ? '' : publicBaseUrl }
225
+ { name: 'AZURE_CLIENT_ID', value: uami.properties.clientId }
226
+ { name: 'NODE_ENV', value: 'production' }
227
+ ]
228
+ }
229
+ ]
230
+ scale: {
231
+ minReplicas: minReplicas
232
+ maxReplicas: maxReplicas
233
+ rules: [
234
+ {
235
+ name: 'http-scale'
236
+ http: { metadata: { concurrentRequests: '10' } }
237
+ }
238
+ ]
239
+ }
240
+ }
241
+ }
242
+ dependsOn: [ roleUami, secretClientId, secretTenantId, secretCloudType ]
243
+ }
244
+
245
+ // ---------- Outputs ----------
246
+ output appFqdn string = app.properties.configuration.ingress.fqdn
247
+ output appUrl string = 'https://${app.properties.configuration.ingress.fqdn}'
248
+ output keyVaultUri string = kv.properties.vaultUri
249
+ output keyVaultName string = kv.name
250
+ output uamiName string = uami.name
251
+ output uamiClientId string = uami.properties.clientId
252
+ output logAnalyticsName string = logAnalytics.name
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
- "version": "0.80.0",
3
+ "version": "0.82.0",
4
4
  "description": " A Model Context Protocol (MCP) server for interacting with Microsoft 365 and Office services through the Graph API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1442,5 +1442,54 @@
1442
1442
  "toolName": "update-place",
1443
1443
  "workScopes": ["Place.ReadWrite.All"],
1444
1444
  "llmTip": "Updates properties of a place (room, room list, building, floor, section, desk, workspace). Body can include: displayName, phone, capacity, building, floorNumber, floorLabel, tags, audioDeviceName, videoDeviceName, displayDeviceName, isWheelChairAccessible. Use get-room or get-room-list to verify current values before updating."
1445
+ },
1446
+ {
1447
+ "pathPattern": "/me/insights/trending",
1448
+ "method": "get",
1449
+ "toolName": "list-trending-insights",
1450
+ "workScopes": ["Sites.Read.All"],
1451
+ "llmTip": "Lists documents trending around the current user. Each item has resourceVisualization (title, type, previewImageUrl, containerDisplayName), resourceReference (webUrl, id, type), and weight (relevance score). Use $filter=resourceVisualization/type eq 'PowerPoint' to filter by file type. Use $orderby=weight/value desc to sort by relevance."
1452
+ },
1453
+ {
1454
+ "pathPattern": "/me/events/{event-id}/cancel",
1455
+ "method": "post",
1456
+ "toolName": "cancel-calendar-event",
1457
+ "scopes": ["Calendars.ReadWrite"],
1458
+ "llmTip": "Cancels a meeting (organizer only) and sends a cancellation message to all attendees. Body: { Comment (optional string, custom message) }. Use this instead of delete-calendar-event when you want attendees to see 'Canceled' in their calendar. Attendees calling this get HTTP 400 — they should use decline-calendar-event instead."
1459
+ },
1460
+ {
1461
+ "pathPattern": "/me/events/{event-id}/forward",
1462
+ "method": "post",
1463
+ "toolName": "forward-calendar-event",
1464
+ "scopes": ["Calendars.ReadWrite"],
1465
+ "llmTip": "Forwards a meeting invitation to additional recipients. Body: { ToRecipients: [{ emailAddress: { address, name } }], Comment (optional) }. If the forwarder is an attendee (not organizer), the organizer is also notified and the new recipient is added to the organizer's attendee list."
1466
+ },
1467
+ {
1468
+ "pathPattern": "/me/events/{event-id}/snoozeReminder",
1469
+ "method": "post",
1470
+ "toolName": "snooze-calendar-event-reminder",
1471
+ "scopes": ["Calendars.ReadWrite"],
1472
+ "llmTip": "Postpones a triggered event reminder. Body: { NewReminderTime: { dateTime (ISO 8601), timeZone (IANA or Windows, e.g. 'Pacific Standard Time') } }. The reminder will re-fire at the new time."
1473
+ },
1474
+ {
1475
+ "pathPattern": "/me/events/{event-id}/dismissReminder",
1476
+ "method": "post",
1477
+ "toolName": "dismiss-calendar-event-reminder",
1478
+ "scopes": ["Calendars.ReadWrite"],
1479
+ "llmTip": "Dismisses a triggered event reminder so it won't re-fire. No request body required. Pair with list-calendar-events or get-schedule to find active reminders."
1480
+ },
1481
+ {
1482
+ "pathPattern": "/me/events/delta()",
1483
+ "method": "get",
1484
+ "toolName": "list-calendar-events-delta",
1485
+ "scopes": ["Calendars.Read"],
1486
+ "llmTip": "Incremental sync of events across the default calendar. First call returns all events plus @odata.deltaLink. Subsequent calls with that link return only additions/updates/removals. Use $select to limit fields. Deltas expire after ~30 days — start over if the server returns 410 Gone. For a time-bounded view with delta semantics, use list-calendar-view-delta instead."
1487
+ },
1488
+ {
1489
+ "pathPattern": "/me/calendarView/delta()",
1490
+ "method": "get",
1491
+ "toolName": "list-calendar-view-delta",
1492
+ "scopes": ["Calendars.Read"],
1493
+ "llmTip": "Incremental sync of events within a time window. Required query params on first call: startDateTime, endDateTime (ISO 8601). Returns events in the window plus @odata.deltaLink; subsequent calls with that link return only changes. Expands recurring events to individual occurrences (unlike list-calendar-events-delta which returns the series master). Use this for calendar UIs showing a week/month view."
1445
1494
  }
1446
1495
  ]