@softeria/ms-365-mcp-server 0.79.5 → 0.80.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
@@ -589,6 +589,10 @@ The Key Vault integration uses `DefaultAzureCredential` from the Azure Identity
589
589
 
590
590
  The Azure Key Vault packages (`@azure/identity` and `@azure/keyvault-secrets`) are optional dependencies. They are only loaded when `MS365_MCP_KEYVAULT_URL` is configured. If you don't use Key Vault, these packages are not required.
591
591
 
592
+ ## Production Deployment
593
+
594
+ See [docs/deployment.md](docs/deployment.md) for a full guide to hosting the server for organization-wide access, including Docker, Azure Container Apps, Azure App Service, Azure AD app registration, reverse proxy setup, client configuration, and exposed endpoints.
595
+
592
596
  ## Contributing
593
597
 
594
598
  We welcome contributions! Before submitting a pull request, please ensure your changes meet our quality standards.
package/dist/cli.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Command } from "commander";
1
+ import { Command, Option } from "commander";
2
2
  import { readFileSync } from "fs";
3
3
  import path from "path";
4
4
  import { fileURLToPath } from "url";
@@ -33,8 +33,13 @@ program.name("ms-365-mcp-server").description("Microsoft 365 MCP Server").versio
33
33
  "--auth-browser",
34
34
  "Use browser-based interactive OAuth flow instead of device code for stdio mode. Opens system browser with localhost callback for seamless sign-in."
35
35
  ).option(
36
- "--base-url <url>",
37
- "Public base URL for OAuth metadata when running behind a reverse proxy (e.g. https://mcp.example.com)"
36
+ "--public-url <url>",
37
+ "Public base URL (e.g. https://mcp.example.com) used in browser-facing OAuth redirects when running behind a reverse proxy. Server-to-server endpoints (token, register) stay on the request host."
38
+ ).addOption(
39
+ // DEPRECATED: kept only so existing deployments that set --base-url or
40
+ // MS365_MCP_BASE_URL do not crash at startup. Use --public-url /
41
+ // MS365_MCP_PUBLIC_URL instead. Hidden from --help; undocumented.
42
+ new Option("--base-url <url>", "deprecated: use --public-url").hideHelp()
38
43
  );
39
44
  function parseArgs() {
40
45
  program.parse();
@@ -7,6 +7,8 @@ import { fileURLToPath } from "url";
7
7
  import { TOOL_CATEGORIES } from "./tool-categories.js";
8
8
  import { getRequestTokens } from "./request-context.js";
9
9
  import { parseTeamsUrl } from "./lib/teams-url-parser.js";
10
+ import { buildBM25Index, scoreQuery, tokenize } from "./lib/bm25.js";
11
+ import { describeToolSchema } from "./lib/tool-schema.js";
10
12
  const __filename = fileURLToPath(import.meta.url);
11
13
  const __dirname = path.dirname(__filename);
12
14
  const endpointsData = JSON.parse(
@@ -484,58 +486,109 @@ function buildToolsRegistry(readOnly, orgMode) {
484
486
  }
485
487
  return toolsMap;
486
488
  }
489
+ function buildDiscoverySearchIndex(toolsRegistry) {
490
+ const TIP_EXCERPT_TOKENS = 12;
491
+ const DESC_CAP_TOKENS = 40;
492
+ const docs = [];
493
+ const nameTokens = /* @__PURE__ */ new Map();
494
+ for (const [name, { tool, config }] of toolsRegistry) {
495
+ const nt = tokenize(name);
496
+ nameTokens.set(name, new Set(nt));
497
+ const pathTokens = tokenize(tool.path);
498
+ const descTokens = tokenize(tool.description).slice(0, DESC_CAP_TOKENS);
499
+ const tipTokens = tokenize(config?.llmTip).slice(0, TIP_EXCERPT_TOKENS);
500
+ const tokens = [
501
+ ...nt,
502
+ ...nt,
503
+ ...nt,
504
+ ...nt,
505
+ ...nt,
506
+ ...pathTokens,
507
+ ...pathTokens,
508
+ ...tipTokens,
509
+ ...descTokens
510
+ ];
511
+ docs.push({ id: name, tokens });
512
+ }
513
+ return { bm25: buildBM25Index(docs), nameTokens };
514
+ }
515
+ function scoreDiscoveryQuery(query, index) {
516
+ const queryTokenSet = new Set(tokenize(query));
517
+ if (queryTokenSet.size === 0) return [];
518
+ const ranked = scoreQuery(query, index.bm25);
519
+ const NAME_BONUS_WEIGHT = 2;
520
+ for (const r of ranked) {
521
+ const nt = index.nameTokens.get(r.id);
522
+ if (!nt || nt.size === 0) continue;
523
+ let matchedIdf = 0;
524
+ let matchedCount = 0;
525
+ for (const qt of queryTokenSet) {
526
+ if (nt.has(qt)) {
527
+ matchedCount++;
528
+ matchedIdf += index.bm25.idf.get(qt) ?? 0;
529
+ }
530
+ }
531
+ if (matchedCount === 0) continue;
532
+ const precision = matchedCount / nt.size;
533
+ r.score += precision * matchedIdf * NAME_BONUS_WEIGHT;
534
+ }
535
+ ranked.sort((a, b) => b.score - a.score);
536
+ return ranked;
537
+ }
487
538
  function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode = false, authManager, _multiAccount = false) {
488
539
  const toolsRegistry = buildToolsRegistry(readOnly, orgMode);
540
+ const searchIndex = buildDiscoverySearchIndex(toolsRegistry);
489
541
  logger.info(`Discovery mode: ${toolsRegistry.size} tools available in registry`);
542
+ const categoryNames = Object.keys(TOOL_CATEGORIES).join(", ");
543
+ const toResultEntry = (name) => {
544
+ const entry = toolsRegistry.get(name);
545
+ if (!entry) return null;
546
+ const { tool, config } = entry;
547
+ return {
548
+ name,
549
+ method: tool.method.toUpperCase(),
550
+ path: tool.path,
551
+ description: tool.description || `${tool.method.toUpperCase()} ${tool.path}`,
552
+ ...config?.llmTip ? { llmTip: config.llmTip } : {}
553
+ };
554
+ };
490
555
  server.tool(
491
556
  "search-tools",
492
- `Search through ${toolsRegistry.size} available Microsoft Graph API tools. Use this to find tools by name, path, or description before executing them.`,
557
+ `Search through ${toolsRegistry.size} Microsoft Graph API tools. Ranks results by BM25 over tool name, llmTip, description, and path (tokenized on hyphens, camelCase, and whitespace). After picking a tool, call get-tool-schema to see its parameters, then execute-tool to invoke it.`,
493
558
  {
494
- query: z.string().describe("Search query to filter tools (searches name, path, and description)").optional(),
495
- category: z.string().describe(
496
- "Filter by category: mail, calendar, files, contacts, tasks, onenote, search, users, excel"
559
+ query: z.string().describe(
560
+ 'Natural-language query. Tokenized and BM25-ranked. E.g. "send email", "create calendar event", "list unread messages".'
497
561
  ).optional(),
498
- limit: z.number().describe("Maximum results to return (default: 20, max: 50)").optional()
562
+ category: z.string().describe(`Optional pre-filter by category: ${categoryNames}`).optional(),
563
+ limit: z.number().describe("Maximum results (default: 10, max: 50)").optional()
499
564
  },
500
565
  {
501
566
  title: "search-tools",
502
567
  readOnlyHint: true,
503
568
  openWorldHint: true
504
- // Searches Microsoft Graph API tools
505
569
  },
506
- async ({ query, category, limit = 20 }) => {
507
- const maxLimit = Math.min(limit, 50);
508
- const results = [];
509
- const queryLower = query?.toLowerCase();
570
+ async ({ query, category, limit = 10 }) => {
571
+ const maxLimit = Math.min(Math.max(limit, 1), 50);
510
572
  const categoryDef = category ? TOOL_CATEGORIES[category] : void 0;
511
- for (const [name, { tool, config }] of toolsRegistry) {
512
- if (categoryDef && !categoryDef.pattern.test(name)) {
513
- continue;
514
- }
515
- if (queryLower) {
516
- const searchText = `${name} ${tool.path} ${tool.description || ""} ${config?.llmTip || ""}`.toLowerCase();
517
- if (!searchText.includes(queryLower)) {
518
- continue;
519
- }
520
- }
521
- results.push({
522
- name,
523
- method: tool.method.toUpperCase(),
524
- path: tool.path,
525
- description: tool.description || `${tool.method.toUpperCase()} ${tool.path}`
526
- });
527
- if (results.length >= maxLimit) break;
573
+ const categoryFilter = (name) => !categoryDef || categoryDef.pattern.test(name);
574
+ let orderedNames;
575
+ if (query && query.trim().length > 0) {
576
+ const ranked = scoreDiscoveryQuery(query, searchIndex);
577
+ orderedNames = ranked.map((r) => r.id).filter(categoryFilter);
578
+ } else {
579
+ orderedNames = [...toolsRegistry.keys()].filter(categoryFilter);
528
580
  }
581
+ const tools = orderedNames.slice(0, maxLimit).map(toResultEntry).filter(Boolean);
529
582
  return {
530
583
  content: [
531
584
  {
532
585
  type: "text",
533
586
  text: JSON.stringify(
534
587
  {
535
- found: results.length,
588
+ found: tools.length,
536
589
  total: toolsRegistry.size,
537
- tools: results,
538
- tip: "Use execute-tool with the tool name and required parameters to call any of these tools."
590
+ tools,
591
+ tip: "Call get-tool-schema(tool_name) to see parameters before invoking execute-tool."
539
592
  },
540
593
  null,
541
594
  2
@@ -545,20 +598,53 @@ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode =
545
598
  };
546
599
  }
547
600
  );
601
+ server.tool(
602
+ "get-tool-schema",
603
+ "Returns the full parameter schema (name, placement, required, JSON Schema) for a tool discovered via search-tools. Call this before execute-tool so you know what parameters to pass and what enum values are valid.",
604
+ {
605
+ tool_name: z.string().describe('Exact tool name from search-tools (e.g. "send-mail")')
606
+ },
607
+ {
608
+ title: "get-tool-schema",
609
+ readOnlyHint: true,
610
+ openWorldHint: false
611
+ },
612
+ async ({ tool_name }) => {
613
+ const entry = toolsRegistry.get(tool_name);
614
+ if (!entry) {
615
+ return {
616
+ content: [
617
+ {
618
+ type: "text",
619
+ text: JSON.stringify({
620
+ error: `Tool not found: ${tool_name}`,
621
+ tip: "Use search-tools to find available tools."
622
+ })
623
+ }
624
+ ],
625
+ isError: true
626
+ };
627
+ }
628
+ const schema = describeToolSchema(entry.tool, entry.config?.llmTip);
629
+ return {
630
+ content: [{ type: "text", text: JSON.stringify(schema, null, 2) }]
631
+ };
632
+ }
633
+ );
548
634
  server.tool(
549
635
  "execute-tool",
550
- "Execute a Microsoft Graph API tool by name. Use search-tools first to find available tools and their parameters. For list endpoints, pass a small $top (or top) first and use $select to limit fields\u2014avoid large page sizes unless the user needs them.",
636
+ "Execute a Microsoft Graph API tool by name. Workflow: search-tools \u2192 get-tool-schema \u2192 execute-tool. Call get-tool-schema first for any tool you have not seen before \u2014 passing the wrong shape to parameters will fail validation or return a Graph 400. For list endpoints, prefer modest $top plus $select.",
551
637
  {
552
638
  tool_name: z.string().describe('Name of the tool to execute (e.g., "list-mail-messages")'),
553
- parameters: z.record(z.any()).describe("Parameters to pass to the tool as key-value pairs").optional()
639
+ parameters: z.record(z.any()).describe(
640
+ 'Parameters shaped per get-tool-schema. Path/query/header params go at the top level; request bodies go under "body".'
641
+ ).optional()
554
642
  },
555
643
  {
556
644
  title: "execute-tool",
557
645
  readOnlyHint: false,
558
646
  destructiveHint: true,
559
- // Can execute any tool, including write operations
560
647
  openWorldHint: true
561
- // Executes against Microsoft Graph API
562
648
  },
563
649
  async ({ tool_name, parameters = {} }) => {
564
650
  const toolData = toolsRegistry.get(tool_name);
@@ -581,6 +667,9 @@ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode =
581
667
  );
582
668
  }
583
669
  export {
670
+ buildDiscoverySearchIndex,
671
+ buildToolsRegistry,
584
672
  registerDiscoveryTools,
585
- registerGraphTools
673
+ registerGraphTools,
674
+ scoreDiscoveryQuery
586
675
  };
@@ -0,0 +1,53 @@
1
+ function tokenize(text) {
2
+ if (!text) return [];
3
+ return text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(/[\s\-_/.,;:(){}[\]'"!?@#$]+/).filter((t) => t.length > 0);
4
+ }
5
+ function buildBM25Index(documents, k1 = 1.2, b = 0.75) {
6
+ const docs = /* @__PURE__ */ new Map();
7
+ const df = /* @__PURE__ */ new Map();
8
+ let totalLen = 0;
9
+ for (const { id, tokens } of documents) {
10
+ const termFreq = /* @__PURE__ */ new Map();
11
+ for (const tok of tokens) {
12
+ termFreq.set(tok, (termFreq.get(tok) ?? 0) + 1);
13
+ }
14
+ for (const tok of termFreq.keys()) {
15
+ df.set(tok, (df.get(tok) ?? 0) + 1);
16
+ }
17
+ docs.set(id, { id, length: tokens.length, termFreq });
18
+ totalLen += tokens.length;
19
+ }
20
+ const N = docs.size;
21
+ const avgdl = N > 0 ? totalLen / N : 0;
22
+ const idf = /* @__PURE__ */ new Map();
23
+ for (const [term, n] of df) {
24
+ idf.set(term, Math.log((N - n + 0.5) / (n + 0.5) + 1));
25
+ }
26
+ return { docs, idf, avgdl, k1, b };
27
+ }
28
+ function scoreQuery(query, index) {
29
+ const queryTokens = [...new Set(tokenize(query))];
30
+ if (queryTokens.length === 0) return [];
31
+ const results = [];
32
+ for (const [id, doc] of index.docs) {
33
+ let score = 0;
34
+ let matched = false;
35
+ for (const qt of queryTokens) {
36
+ const tf = doc.termFreq.get(qt);
37
+ if (!tf) continue;
38
+ matched = true;
39
+ const idf = index.idf.get(qt) ?? 0;
40
+ const num = tf * (index.k1 + 1);
41
+ const den = tf + index.k1 * (1 - index.b + index.b * doc.length / (index.avgdl || 1));
42
+ score += idf * (num / den);
43
+ }
44
+ if (matched) results.push({ id, score });
45
+ }
46
+ results.sort((a, b) => b.score - a.score);
47
+ return results;
48
+ }
49
+ export {
50
+ buildBM25Index,
51
+ scoreQuery,
52
+ tokenize
53
+ };
@@ -0,0 +1,35 @@
1
+ import { zodToJsonSchema } from "zod-to-json-schema";
2
+ function unwrapOptional(schema) {
3
+ const def = schema._def;
4
+ const typeName = def?.typeName;
5
+ if (typeName === "ZodOptional" || typeName === "ZodDefault" || typeName === "ZodNullable") {
6
+ return { inner: def.innerType, optional: true };
7
+ }
8
+ return { inner: schema, optional: false };
9
+ }
10
+ function describeToolSchema(tool, llmTip) {
11
+ const params = (tool.parameters ?? []).map((p) => {
12
+ const { inner, optional } = unwrapOptional(p.schema);
13
+ const isPath = p.type === "Path";
14
+ const jsonSchema = zodToJsonSchema(inner, { target: "jsonSchema7", $refStrategy: "none" });
15
+ const { $schema: _s, ...schema } = jsonSchema;
16
+ return {
17
+ name: p.name,
18
+ in: p.type,
19
+ required: isPath || !optional,
20
+ description: p.description,
21
+ schema
22
+ };
23
+ });
24
+ return {
25
+ name: tool.alias,
26
+ method: tool.method.toUpperCase(),
27
+ path: tool.path,
28
+ description: tool.description ?? "",
29
+ ...llmTip ? { llmTip } : {},
30
+ parameters: params
31
+ };
32
+ }
33
+ export {
34
+ describeToolSchema
35
+ };
@@ -14,7 +14,7 @@ function buildGeneralMcpInstructions(opts) {
14
14
  parts.push("Work/school-only tools require starting the server with --org-mode.");
15
15
  return parts.join(" ");
16
16
  }
17
- const DISCOVERY_MODE_INSTRUCTIONS_ADDON = "DISCOVERY MODE ADD-ON: Graph is reached via search-tools then execute-tool (plus auth helpers). Call search-tools with short keywords, then execute-tool with tool_name exactly as returned; put Graph parameters in the parameters object. If search-tools returns no matches, retry with shorter or different keywords.";
17
+ const DISCOVERY_MODE_INSTRUCTIONS_ADDON = "DISCOVERY MODE ADD-ON: Graph is reached via search-tools \u2192 get-tool-schema \u2192 execute-tool (plus auth helpers). Workflow: (1) call search-tools with short natural-language keywords (BM25-ranked); (2) call get-tool-schema(tool_name) to see the parameters, required fields, and enum values; (3) call execute-tool with tool_name exactly as returned and parameters shaped per the schema. Skipping get-tool-schema is the leading cause of Graph 400 errors here. If search-tools returns no matches, retry with shorter or different keywords.";
18
18
  function buildMcpServerInstructions(opts) {
19
19
  const general = buildGeneralMcpInstructions(opts);
20
20
  if (!opts.discovery) return general;
package/dist/server.js CHANGED
@@ -147,14 +147,17 @@ class MicrosoftGraphServer {
147
147
  next();
148
148
  });
149
149
  const oauthProvider = new MicrosoftOAuthProvider(this.authManager, this.secrets);
150
+ const publicUrlRaw = this.options.publicUrl || process.env.MS365_MCP_PUBLIC_URL || this.options.baseUrl || process.env.MS365_MCP_BASE_URL || null;
151
+ const publicBase = publicUrlRaw ? new URL(publicUrlRaw).href.replace(/\/$/, "") : null;
150
152
  app.get("/.well-known/oauth-authorization-server", async (req, res) => {
151
153
  const protocol = req.secure ? "https" : "http";
152
- const url = new URL(`${protocol}://${req.get("host")}`);
154
+ const requestOrigin = `${protocol}://${req.get("host")}`;
155
+ const browserBase = publicBase ?? requestOrigin;
153
156
  const scopes = buildScopesFromEndpoints(this.options.orgMode, this.options.enabledTools);
154
157
  const metadata = {
155
- issuer: url.origin,
156
- authorization_endpoint: `${url.origin}/authorize`,
157
- token_endpoint: `${url.origin}/token`,
158
+ issuer: browserBase,
159
+ authorization_endpoint: `${browserBase}/authorize`,
160
+ token_endpoint: `${requestOrigin}/token`,
158
161
  response_types_supported: ["code"],
159
162
  response_modes_supported: ["query"],
160
163
  grant_types_supported: ["authorization_code", "refresh_token"],
@@ -163,20 +166,21 @@ class MicrosoftGraphServer {
163
166
  scopes_supported: scopes
164
167
  };
165
168
  if (this.options.enableDynamicRegistration) {
166
- metadata.registration_endpoint = `${url.origin}/register`;
169
+ metadata.registration_endpoint = `${requestOrigin}/register`;
167
170
  }
168
171
  res.json(metadata);
169
172
  });
170
173
  app.get("/.well-known/oauth-protected-resource", async (req, res) => {
171
174
  const protocol = req.secure ? "https" : "http";
172
- const url = new URL(`${protocol}://${req.get("host")}`);
175
+ const requestOrigin = `${protocol}://${req.get("host")}`;
176
+ const browserBase = publicBase ?? requestOrigin;
173
177
  const scopes = buildScopesFromEndpoints(this.options.orgMode, this.options.enabledTools);
174
178
  res.json({
175
- resource: `${url.origin}/mcp`,
176
- authorization_servers: [url.origin],
179
+ resource: `${requestOrigin}/mcp`,
180
+ authorization_servers: [browserBase],
177
181
  scopes_supported: scopes,
178
182
  bearer_methods_supported: ["header"],
179
- resource_documentation: `${url.origin}`
183
+ resource_documentation: browserBase
180
184
  });
181
185
  });
182
186
  if (this.options.enableDynamicRegistration) {
@@ -362,9 +366,7 @@ class MicrosoftGraphServer {
362
366
  app.use(
363
367
  mcpAuthRouter({
364
368
  provider: oauthProvider,
365
- issuerUrl: new URL(
366
- this.options.baseUrl || process.env.MS365_MCP_BASE_URL || `http://localhost:${port}`
367
- )
369
+ issuerUrl: new URL(publicBase ?? `http://localhost:${port}`)
368
370
  })
369
371
  );
370
372
  app.get(
@@ -0,0 +1,189 @@
1
+ # Production Deployment
2
+
3
+ The server can be hosted centrally so that multiple users in an organization share a single MCP endpoint. Each user
4
+ authenticates with their own Microsoft account via OAuth — the server is stateless and does not store tokens.
5
+
6
+ ## Architecture
7
+
8
+ ```
9
+ MCP Clients (Claude Desktop, Claude Code, Open WebUI, ...)
10
+ │ Streamable HTTP + OAuth 2.1
11
+
12
+ ┌─────────────────────────────┐
13
+ │ ms-365-mcp-server --http │ Azure Container Apps / App Service / Docker
14
+ │ (stateless, no token store)│
15
+ └─────────────┬───────────────┘
16
+ │ Bearer token (per-user)
17
+
18
+ Microsoft Graph API
19
+ ```
20
+
21
+ ## Docker
22
+
23
+ A `Dockerfile` is included for containerized deployments:
24
+
25
+ ```bash
26
+ # Build the image
27
+ docker build -t ms-365-mcp-server .
28
+
29
+ # Run with environment variables
30
+ docker run -p 3000:3000 \
31
+ -e MS365_MCP_CLIENT_ID=your-client-id \
32
+ -e MS365_MCP_TENANT_ID=your-tenant-id \
33
+ -e MS365_MCP_CLIENT_SECRET=your-secret \
34
+ -e MS365_MCP_ORG_MODE=true \
35
+ ms-365-mcp-server \
36
+ --http 3000 --org-mode
37
+ ```
38
+
39
+ For production, use Azure Key Vault instead of environment variables for secrets (see [Azure Key Vault Integration](../README.md#azure-key-vault-integration)):
40
+
41
+ ```bash
42
+ docker run -p 3000:3000 \
43
+ -e MS365_MCP_KEYVAULT_URL=https://your-keyvault.vault.azure.net \
44
+ -e MS365_MCP_ORG_MODE=true \
45
+ -e MS365_MCP_PUBLIC_URL=https://mcp.example.com \
46
+ ms-365-mcp-server \
47
+ --http 3000 --org-mode
48
+ ```
49
+
50
+ ## Azure Container Apps
51
+
52
+ 1. **Push the image** to Azure Container Registry:
53
+
54
+ ```bash
55
+ az acr build --registry yourregistry --image ms365-mcp-server:latest .
56
+ ```
57
+
58
+ 2. **Create the Container App** with system-assigned managed identity:
59
+
60
+ ```bash
61
+ az containerapp create \
62
+ --name mcp-server \
63
+ --resource-group your-rg \
64
+ --environment your-cae \
65
+ --image yourregistry.azurecr.io/ms365-mcp-server:latest \
66
+ --target-port 3000 \
67
+ --ingress external \
68
+ --min-replicas 1 \
69
+ --max-replicas 3 \
70
+ --cpu 0.5 --memory 1Gi \
71
+ --system-assigned \
72
+ --env-vars \
73
+ "MS365_MCP_KEYVAULT_URL=https://your-keyvault.vault.azure.net" \
74
+ "MS365_MCP_ORG_MODE=true" \
75
+ "MS365_MCP_PUBLIC_URL=https://mcp.example.com" \
76
+ --command "node" "dist/index.js" "--http" "3000" "--org-mode"
77
+ ```
78
+
79
+ 3. **Grant Key Vault access** to the managed identity:
80
+
81
+ ```bash
82
+ PRINCIPAL_ID=$(az containerapp show --name mcp-server --resource-group your-rg \
83
+ --query identity.principalId -o tsv)
84
+ az keyvault set-policy --name your-keyvault --object-id $PRINCIPAL_ID \
85
+ --secret-permissions get list
86
+ ```
87
+
88
+ ## Azure App Service
89
+
90
+ ```bash
91
+ az webapp create \
92
+ --name mcp-server \
93
+ --resource-group your-rg \
94
+ --plan your-plan \
95
+ --runtime "NODE:20-lts" \
96
+ --assign-identity
97
+
98
+ az webapp config appsettings set --name mcp-server --resource-group your-rg \
99
+ --settings \
100
+ MS365_MCP_KEYVAULT_URL="https://your-keyvault.vault.azure.net" \
101
+ MS365_MCP_ORG_MODE="true" \
102
+ MS365_MCP_PUBLIC_URL="https://mcp-server.azurewebsites.net" \
103
+ WEBSITES_PORT="3000"
104
+
105
+ az webapp config set --name mcp-server --resource-group your-rg \
106
+ --startup-file "node dist/index.js --http 3000 --org-mode"
107
+ ```
108
+
109
+ ## Azure AD App Registration (for organizations)
110
+
111
+ When deploying for an organization, create a dedicated app registration instead of using the built-in client ID:
112
+
113
+ 1. **Create the app** in [Azure Portal](https://portal.azure.com) > App registrations > New registration
114
+ - Name: `MS365 MCP Server`
115
+ - Supported account types: **Accounts in this organizational directory only** (single tenant)
116
+ - Redirect URI: your server's callback URL
117
+
118
+ 2. **Add API permissions** > Microsoft Graph > Delegated permissions
119
+ Run `npx @softeria/ms-365-mcp-server --org-mode --list-permissions` to print the exact list of permissions required for your enabled tools.
120
+
121
+ 3. **Grant admin consent** to skip per-user consent prompts:
122
+
123
+ ```bash
124
+ az ad app permission admin-consent --id your-app-client-id
125
+ ```
126
+
127
+ 4. **Create a client secret** under Certificates & secrets, then store it in Key Vault
128
+
129
+ 5. **Store credentials** in Key Vault (see [Azure Key Vault Integration](../README.md#azure-key-vault-integration))
130
+
131
+ ## Reverse Proxy / Custom Domain
132
+
133
+ When running behind a reverse proxy, set `MS365_MCP_PUBLIC_URL` so that the OAuth authorize URL handed back to the user's browser is resolvable from outside the server's network:
134
+
135
+ ```bash
136
+ # Via environment variable
137
+ MS365_MCP_PUBLIC_URL=https://mcp.example.com
138
+
139
+ # Or via CLI flag
140
+ --public-url https://mcp.example.com
141
+ ```
142
+
143
+ Only browser-facing fields (`issuer`, `authorization_endpoint`, `authorization_servers`) are pinned to this URL. Server-to-server endpoints (`token_endpoint`, `registration_endpoint`, `resource`) stay on the request origin, so clients that reach the server over an internal network (e.g. another container on the same Docker network) don't have to round-trip back through the public URL.
144
+
145
+ ## Client Configuration
146
+
147
+ Once deployed, users connect by pointing their MCP client to the server URL:
148
+
149
+ **Claude Desktop:**
150
+
151
+ ```json
152
+ {
153
+ "mcpServers": {
154
+ "ms365": {
155
+ "type": "streamable-http",
156
+ "url": "https://mcp.example.com/mcp"
157
+ }
158
+ }
159
+ }
160
+ ```
161
+
162
+ **Claude Code:**
163
+
164
+ ```bash
165
+ claude mcp add ms365 --transport http https://mcp.example.com/mcp
166
+ ```
167
+
168
+ The client automatically discovers OAuth endpoints and opens a browser for authentication on first use.
169
+
170
+ ## Security Considerations
171
+
172
+ - **Stateless**: the server does not store tokens — each request carries the user's Bearer token
173
+ - **Admin consent**: grant tenant-wide consent to avoid per-user consent prompts
174
+ - **Managed identity**: use managed identity for Key Vault access (no secrets in environment variables)
175
+ - **Read-only mode**: use `--read-only` to disable all write operations (send, delete, update, create)
176
+ - **Tool filtering**: use `--enabled-tools <regex>` or `--preset <names>` to restrict available tools
177
+ - **CORS**: configure `MS365_MCP_CORS_ORIGIN` to restrict allowed origins (defaults to `http://localhost:3000`); set explicitly when clients run on a different origin
178
+
179
+ ## Exposed Endpoints
180
+
181
+ | Path | Method | Description | Auth Required |
182
+ | ----------------------------------------- | -------- | ------------------------------- | ------------- |
183
+ | `/` | GET | Health check | No |
184
+ | `/mcp` | GET/POST | MCP protocol endpoint | Bearer token |
185
+ | `/authorize` | GET | OAuth — redirect to Microsoft | No |
186
+ | `/token` | POST | OAuth — code exchange / refresh | No |
187
+ | `/register` | POST | OAuth — dynamic registration | No |
188
+ | `/.well-known/oauth-authorization-server` | GET | OAuth server metadata | No |
189
+ | `/.well-known/oauth-protected-resource` | GET | Protected resource metadata | No |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
- "version": "0.79.5",
3
+ "version": "0.80.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",
@@ -42,7 +42,8 @@
42
42
  "js-yaml": "^4.1.0",
43
43
  "open": "^11.0.0",
44
44
  "winston": "^3.17.0",
45
- "zod": "^3.24.2"
45
+ "zod": "^3.24.2",
46
+ "zod-to-json-schema": "^3.25.1"
46
47
  },
47
48
  "optionalDependencies": {
48
49
  "@azure/identity": "^4.5.0",