@softeria/ms-365-mcp-server 0.80.0 → 0.81.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,12 @@
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."
1445
1452
  }
1446
1453
  ]
@@ -2455,6 +2455,51 @@ 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 microsoft_graph_resourceReference = z.object({
2459
+ id: z.string().describe("The item's unique identifier.").nullish(),
2460
+ type: z.string().describe(
2461
+ "A string value that can be used to classify the item, such as 'microsoft.graph.driveItem'"
2462
+ ).nullish(),
2463
+ webUrl: z.string().describe("A URL leading to the referenced item.").nullish()
2464
+ }).passthrough();
2465
+ const microsoft_graph_resourceVisualization = z.object({
2466
+ containerDisplayName: z.string().describe(
2467
+ "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."
2468
+ ).nullish(),
2469
+ containerType: z.string().describe(
2470
+ "Can be used for filtering by the type of container in which the file is stored. Such as Site or OneDriveBusiness."
2471
+ ).nullish(),
2472
+ containerWebUrl: z.string().describe("A path leading to the folder in which the item is stored.").nullish(),
2473
+ mediaType: z.string().describe(
2474
+ "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."
2475
+ ).nullish(),
2476
+ previewImageUrl: z.string().describe("A URL leading to the preview image for the item.").nullish(),
2477
+ previewText: z.string().describe("A preview text for the item.").nullish(),
2478
+ title: z.string().describe("The item's title text.").nullish(),
2479
+ type: z.string().describe(
2480
+ "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."
2481
+ ).nullish()
2482
+ }).passthrough();
2483
+ const microsoft_graph_entity = z.object({ id: z.string().describe("The unique identifier for an entity. Read-only.").optional() }).passthrough();
2484
+ const microsoft_graph_trending = z.object({
2485
+ id: z.string().describe("The unique identifier for an entity. Read-only.").optional(),
2486
+ lastModifiedDateTime: z.string().regex(
2487
+ /^[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])$/
2488
+ ).datetime({ offset: true }).describe(
2489
+ "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"
2490
+ ).nullish(),
2491
+ resourceReference: microsoft_graph_resourceReference.optional(),
2492
+ resourceVisualization: microsoft_graph_resourceVisualization.optional(),
2493
+ weight: z.number().describe(
2494
+ "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]"
2495
+ ).nullish(),
2496
+ resource: microsoft_graph_entity.optional()
2497
+ }).passthrough();
2498
+ const microsoft_graph_trendingCollectionResponse = z.object({
2499
+ "@odata.count": z.number().int().nullable(),
2500
+ "@odata.nextLink": z.string().nullable(),
2501
+ value: z.array(microsoft_graph_trending)
2502
+ }).partial().passthrough();
2458
2503
  const microsoft_graph_giphyRatingType = z.enum(["strict", "moderate", "unknownFutureValue"]);
2459
2504
  const microsoft_graph_teamFunSettings = z.object({
2460
2505
  allowCustomMemes: z.boolean().describe("If set to true, enables users to include custom memes.").nullish(),
@@ -3918,7 +3963,6 @@ const microsoft_graph_searchAggregation = z.object({
3918
3963
  buckets: z.array(microsoft_graph_searchBucket).optional(),
3919
3964
  field: z.string().nullish()
3920
3965
  }).passthrough();
3921
- const microsoft_graph_entity = z.object({ id: z.string().describe("The unique identifier for an entity. Read-only.").optional() }).passthrough();
3922
3966
  const microsoft_graph_searchHit = z.object({
3923
3967
  contentSource: z.string().describe("The name of the content source that the externalItem is part of.").nullish(),
3924
3968
  hitId: z.string().describe(
@@ -4504,6 +4548,11 @@ const schemas = {
4504
4548
  microsoft_graph_driveCollectionResponse,
4505
4549
  accept_calendar_event_Body,
4506
4550
  decline_calendar_event_Body,
4551
+ microsoft_graph_resourceReference,
4552
+ microsoft_graph_resourceVisualization,
4553
+ microsoft_graph_entity,
4554
+ microsoft_graph_trending,
4555
+ microsoft_graph_trendingCollectionResponse,
4507
4556
  microsoft_graph_giphyRatingType,
4508
4557
  microsoft_graph_teamFunSettings,
4509
4558
  microsoft_graph_teamGuestSettings,
@@ -4650,7 +4699,6 @@ const schemas = {
4650
4699
  search_query_Body,
4651
4700
  microsoft_graph_searchBucket,
4652
4701
  microsoft_graph_searchAggregation,
4653
- microsoft_graph_entity,
4654
4702
  microsoft_graph_searchHit,
4655
4703
  microsoft_graph_searchHitsContainer,
4656
4704
  microsoft_graph_alteredQueryToken,
@@ -7347,6 +7395,56 @@ Based on this value, you can better adjust the parameters and call findMeetingTi
7347
7395
  ],
7348
7396
  response: z.void()
7349
7397
  },
7398
+ {
7399
+ method: "get",
7400
+ path: "/me/insights/trending",
7401
+ alias: "list-trending-insights",
7402
+ description: `Calculated insight that includes a list of documents trending around the user.`,
7403
+ requestFormat: "json",
7404
+ parameters: [
7405
+ {
7406
+ name: "$top",
7407
+ type: "Query",
7408
+ schema: z.number().int().gte(0).describe("Show only the first n items").optional()
7409
+ },
7410
+ {
7411
+ name: "$skip",
7412
+ type: "Query",
7413
+ schema: z.number().int().gte(0).describe("Skip the first n items").optional()
7414
+ },
7415
+ {
7416
+ name: "$search",
7417
+ type: "Query",
7418
+ schema: z.string().describe("Search items by search phrases").optional()
7419
+ },
7420
+ {
7421
+ name: "$filter",
7422
+ type: "Query",
7423
+ schema: z.string().describe("Filter items by property values").optional()
7424
+ },
7425
+ {
7426
+ name: "$count",
7427
+ type: "Query",
7428
+ schema: z.boolean().describe("Include count of items").optional()
7429
+ },
7430
+ {
7431
+ name: "$orderby",
7432
+ type: "Query",
7433
+ schema: z.array(z.string()).describe("Order items by property values").optional()
7434
+ },
7435
+ {
7436
+ name: "$select",
7437
+ type: "Query",
7438
+ schema: z.array(z.string()).describe("Select properties to be returned").optional()
7439
+ },
7440
+ {
7441
+ name: "$expand",
7442
+ type: "Query",
7443
+ schema: z.array(z.string()).describe("Expand related entities").optional()
7444
+ }
7445
+ ],
7446
+ response: z.void()
7447
+ },
7350
7448
  {
7351
7449
  method: "get",
7352
7450
  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.81.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,12 @@
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."
1445
1452
  }
1446
1453
  ]