@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 +13 -2
- package/dist/endpoints.json +49 -0
- package/dist/generated/client.js +294 -2
- package/docs/deployment.md +2 -0
- package/examples/azure-container-apps/README.md +188 -0
- package/examples/azure-container-apps/deploy.ps1 +170 -0
- package/examples/azure-container-apps/main.bicep +252 -0
- package/package.json +1 -1
- package/src/endpoints.json +49 -0
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
|

|
|
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
|
-
|
|
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
|
|
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:
|
package/dist/endpoints.json
CHANGED
|
@@ -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
|
]
|
package/dist/generated/client.js
CHANGED
|
@@ -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",
|
package/docs/deployment.md
CHANGED
|
@@ -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.
|
|
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",
|
package/src/endpoints.json
CHANGED
|
@@ -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
|
]
|