@softeria/ms-365-mcp-server 0.79.5 → 0.79.6
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 +4 -0
- package/dist/graph-tools.js +124 -35
- package/dist/lib/bm25.js +53 -0
- package/dist/lib/tool-schema.js +35 -0
- package/dist/mcp-instructions.js +1 -1
- package/docs/deployment.md +189 -0
- package/package.json +3 -2
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/graph-tools.js
CHANGED
|
@@ -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}
|
|
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(
|
|
495
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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:
|
|
588
|
+
found: tools.length,
|
|
536
589
|
total: toolsRegistry.size,
|
|
537
|
-
tools
|
|
538
|
-
tip: "
|
|
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.
|
|
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(
|
|
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
|
};
|
package/dist/lib/bm25.js
ADDED
|
@@ -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
|
+
};
|
package/dist/mcp-instructions.js
CHANGED
|
@@ -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
|
|
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;
|
|
@@ -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_BASE_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_BASE_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_BASE_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 or custom domain, set `MS365_MCP_BASE_URL` so OAuth discovery endpoints return the correct public URL:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
# Via environment variable
|
|
137
|
+
MS365_MCP_BASE_URL=https://mcp.example.com
|
|
138
|
+
|
|
139
|
+
# Or via CLI flag
|
|
140
|
+
--base-url https://mcp.example.com
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Without this, `/.well-known/oauth-authorization-server` would advertise `http://localhost:3000` as the authorization endpoint.
|
|
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.
|
|
3
|
+
"version": "0.79.6",
|
|
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",
|