@softeria/ms-365-mcp-server 0.95.0 → 0.97.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.
@@ -8,7 +8,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
10
  import { buildBM25Index, scoreQuery, tokenize } from "./lib/bm25.js";
11
- import { describeToolSchema } from "./lib/tool-schema.js";
11
+ import { describeToolSchema, describeUtilityToolSchema } from "./lib/tool-schema.js";
12
12
  const __filename = fileURLToPath(import.meta.url);
13
13
  const __dirname = path.dirname(__filename);
14
14
  const endpointsData = JSON.parse(
@@ -34,6 +34,111 @@ function clampTopQueryParam(queryParams) {
34
34
  logger.info(`Clamping $top from ${requested} to ${cap} (MS365_MCP_MAX_TOP)`);
35
35
  queryParams["$top"] = String(cap);
36
36
  }
37
+ const UTILITY_TOOLS = [
38
+ {
39
+ name: "parse-teams-url",
40
+ method: "POST",
41
+ path: "tool:parse-teams-url",
42
+ description: "Converts any Teams meeting URL format (short /meet/, full /meetup-join/, or recap ?threadId=) into a standard joinWebUrl. Use this before list-online-meetings when the user provides a recap or short URL.",
43
+ readOnlyHint: true,
44
+ openWorldHint: false,
45
+ buildSchema: () => ({
46
+ url: z.string().describe("Teams meeting URL in any format")
47
+ }),
48
+ execute: async (params) => {
49
+ const url = params.url;
50
+ if (typeof url !== "string") {
51
+ return {
52
+ content: [{ type: "text", text: JSON.stringify({ error: "url is required." }) }],
53
+ isError: true
54
+ };
55
+ }
56
+ try {
57
+ const joinWebUrl = parseTeamsUrl(url);
58
+ return { content: [{ type: "text", text: joinWebUrl }] };
59
+ } catch (error) {
60
+ return {
61
+ content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
62
+ isError: true
63
+ };
64
+ }
65
+ }
66
+ },
67
+ {
68
+ name: "download-bytes",
69
+ method: "GET",
70
+ path: "tool:download-bytes",
71
+ description: 'Download binary content from Microsoft Graph and return it as base64. Single tool for any binary read: drive file content, mail attachment, profile photo, Teams hosted content, meeting recording. Returns { contentType, encoding: "base64", contentLength, contentBytes }.',
72
+ readOnlyHint: true,
73
+ openWorldHint: true,
74
+ buildSchema: (ctx) => {
75
+ const schema = {
76
+ target: z.string().describe(
77
+ 'Relative Microsoft Graph path starting with "/". Common paths: /drives/{drive-id}/items/{driveItem-id}/content (drive file content); /me/messages/{message-id}/attachments/{attachment-id}/$value (mail attachment, list-mail-attachments returns the IDs); /me/photo/$value or /users/{user-id}/photo/$value (profile photo); /chats/{chat-id}/messages/{chatMessage-id}/hostedContents/{chatMessageHostedContent-id}/$value (Teams chat hosted content, list-chat-message-hosted-contents returns the IDs); /teams/{team-id}/channels/{channel-id}/messages/{chatMessage-id}/hostedContents/{chatMessageHostedContent-id}/$value (Teams channel hosted content). For meeting recordings (often large), use get-meeting-recording-content which returns a URL for out-of-band download by the client.'
78
+ )
79
+ };
80
+ if (ctx.multiAccount) {
81
+ schema["account"] = z.string().optional().describe(
82
+ "Account to use when multiple Microsoft accounts are configured. Required when multiple accounts exist (see list-accounts)."
83
+ );
84
+ }
85
+ return schema;
86
+ },
87
+ execute: async (params, { graphClient, authManager }) => {
88
+ const target = params.target;
89
+ const accountParam = params.account;
90
+ if (typeof target !== "string" || target.length === 0) {
91
+ return {
92
+ content: [
93
+ {
94
+ type: "text",
95
+ text: JSON.stringify({ error: "target is required and must be a non-empty string." })
96
+ }
97
+ ],
98
+ isError: true
99
+ };
100
+ }
101
+ if (!target.startsWith("/")) {
102
+ return {
103
+ content: [
104
+ {
105
+ type: "text",
106
+ text: JSON.stringify({
107
+ error: 'target must be a relative Microsoft Graph path starting with "/", e.g. /me/photo/$value or /drives/{drive-id}/items/{driveItem-id}/content. Absolute URLs are not accepted; if you have an @microsoft.graph.downloadUrl, use the equivalent /content or /$value path instead (Graph 302-redirects to the same bytes).'
108
+ })
109
+ }
110
+ ],
111
+ isError: true
112
+ };
113
+ }
114
+ try {
115
+ let accountAccessToken;
116
+ if (authManager && !authManager.isOAuthModeEnabled() && !getRequestTokens()) {
117
+ accountAccessToken = await authManager.getTokenForAccount(accountParam);
118
+ }
119
+ return await graphClient.graphRequest(target, { accessToken: accountAccessToken });
120
+ } catch (error) {
121
+ return {
122
+ content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
123
+ isError: true
124
+ };
125
+ }
126
+ }
127
+ }
128
+ ];
129
+ function registerUtilityToolWithMcp(server, utility, ctx) {
130
+ server.tool(
131
+ utility.name,
132
+ utility.description,
133
+ utility.buildSchema(ctx),
134
+ {
135
+ title: utility.name,
136
+ readOnlyHint: utility.readOnlyHint ?? true,
137
+ openWorldHint: utility.openWorldHint ?? true
138
+ },
139
+ async (params) => utility.execute(params, ctx)
140
+ );
141
+ }
37
142
  async function executeGraphTool(tool, config, graphClient, params, authManager) {
38
143
  logger.info(`Tool ${tool.alias} called with params: ${JSON.stringify(params)}`);
39
144
  try {
@@ -174,7 +279,12 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
174
279
  headers
175
280
  };
176
281
  if (options.method !== "GET" && body) {
177
- if (config?.contentType === "text/html") {
282
+ if (tool.requestFormat === "binary" && typeof body === "string") {
283
+ options.body = Buffer.from(body, "base64");
284
+ if (!config?.contentType) {
285
+ headers["Content-Type"] = "application/octet-stream";
286
+ }
287
+ } else if (config?.contentType === "text/html") {
178
288
  if (typeof body === "string") {
179
289
  options.body = body;
180
290
  } else if (typeof body === "object" && "content" in body) {
@@ -426,36 +536,19 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
426
536
  if (multiAccount) {
427
537
  logger.info('Multi-account mode: "account" parameter injected into all tool schemas');
428
538
  }
429
- if (!enabledToolsRegex || enabledToolsRegex.test("parse-teams-url")) {
539
+ const utilityCtx = {
540
+ graphClient,
541
+ authManager,
542
+ multiAccount,
543
+ accountNames
544
+ };
545
+ for (const utility of UTILITY_TOOLS) {
546
+ if (enabledToolsRegex && !enabledToolsRegex.test(utility.name)) continue;
430
547
  try {
431
- server.tool(
432
- "parse-teams-url",
433
- "Converts any Teams meeting URL format (short /meet/, full /meetup-join/, or recap ?threadId=) into a standard joinWebUrl. Use this before list-online-meetings when the user provides a recap or short URL.",
434
- {
435
- url: z.string().describe("Teams meeting URL in any format")
436
- },
437
- {
438
- title: "parse-teams-url",
439
- readOnlyHint: true,
440
- openWorldHint: false
441
- },
442
- async ({ url }) => {
443
- try {
444
- const joinWebUrl = parseTeamsUrl(url);
445
- return { content: [{ type: "text", text: joinWebUrl }] };
446
- } catch (error) {
447
- return {
448
- content: [
449
- { type: "text", text: JSON.stringify({ error: error.message }) }
450
- ],
451
- isError: true
452
- };
453
- }
454
- }
455
- );
548
+ registerUtilityToolWithMcp(server, utility, utilityCtx);
456
549
  registeredCount++;
457
550
  } catch (error) {
458
- logger.error(`Failed to register tool parse-teams-url: ${error.message}`);
551
+ logger.error(`Failed to register tool ${utility.name}: ${error.message}`);
459
552
  failedCount++;
460
553
  }
461
554
  }
@@ -481,7 +574,7 @@ function buildToolsRegistry(readOnly, orgMode) {
481
574
  }
482
575
  return toolsMap;
483
576
  }
484
- function buildDiscoverySearchIndex(toolsRegistry) {
577
+ function buildDiscoverySearchIndex(toolsRegistry, utilityTools = []) {
485
578
  const TIP_EXCERPT_TOKENS = 12;
486
579
  const DESC_CAP_TOKENS = 40;
487
580
  const docs = [];
@@ -505,6 +598,14 @@ function buildDiscoverySearchIndex(toolsRegistry) {
505
598
  ];
506
599
  docs.push({ id: name, tokens });
507
600
  }
601
+ for (const utility of utilityTools) {
602
+ const nt = tokenize(utility.name);
603
+ nameTokens.set(utility.name, new Set(nt));
604
+ const pathTokens = tokenize(utility.path);
605
+ const descTokens = tokenize(utility.description).slice(0, DESC_CAP_TOKENS);
606
+ const tokens = [...nt, ...nt, ...nt, ...nt, ...nt, ...pathTokens, ...pathTokens, ...descTokens];
607
+ docs.push({ id: utility.name, tokens });
608
+ }
508
609
  return { bm25: buildBM25Index(docs), nameTokens };
509
610
  }
510
611
  function scoreDiscoveryQuery(query, index) {
@@ -530,29 +631,51 @@ function scoreDiscoveryQuery(query, index) {
530
631
  ranked.sort((a, b) => b.score - a.score);
531
632
  return ranked;
532
633
  }
533
- function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode = false, authManager, _multiAccount = false) {
634
+ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode = false, authManager, multiAccount = false, accountNames = []) {
534
635
  const toolsRegistry = buildToolsRegistry(readOnly, orgMode);
535
- const searchIndex = buildDiscoverySearchIndex(toolsRegistry);
536
- logger.info(`Discovery mode: ${toolsRegistry.size} tools available in registry`);
636
+ const utilityTools = UTILITY_TOOLS;
637
+ const searchIndex = buildDiscoverySearchIndex(toolsRegistry, utilityTools);
638
+ const totalCount = toolsRegistry.size + utilityTools.length;
639
+ logger.info(
640
+ `Discovery mode: ${totalCount} tools (${toolsRegistry.size} Graph + ${utilityTools.length} utility)`
641
+ );
642
+ const utilityCtx = {
643
+ graphClient,
644
+ authManager,
645
+ multiAccount,
646
+ accountNames
647
+ };
648
+ const utilityByName = new Map(utilityTools.map((u) => [u.name, u]));
537
649
  const categoryNames = Object.keys(TOOL_CATEGORIES).join(", ");
538
650
  const toResultEntry = (name) => {
539
651
  const entry = toolsRegistry.get(name);
540
- if (!entry) return null;
541
- const { tool, config } = entry;
542
- return {
543
- name,
544
- method: tool.method.toUpperCase(),
545
- path: tool.path,
546
- description: tool.description || `${tool.method.toUpperCase()} ${tool.path}`,
547
- ...config?.llmTip ? { llmTip: config.llmTip } : {}
548
- };
652
+ if (entry) {
653
+ const { tool, config } = entry;
654
+ return {
655
+ name,
656
+ method: tool.method.toUpperCase(),
657
+ path: tool.path,
658
+ description: tool.description || `${tool.method.toUpperCase()} ${tool.path}`,
659
+ ...config?.llmTip ? { llmTip: config.llmTip } : {}
660
+ };
661
+ }
662
+ const utility = utilityByName.get(name);
663
+ if (utility) {
664
+ return {
665
+ name: utility.name,
666
+ method: utility.method,
667
+ path: utility.path,
668
+ description: utility.description
669
+ };
670
+ }
671
+ return null;
549
672
  };
550
673
  server.tool(
551
674
  "search-tools",
552
- `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.`,
675
+ `Search through ${totalCount} tools (${toolsRegistry.size} Microsoft Graph API operations + ${utilityTools.length} server utilities like download-bytes). Ranks results by BM25 over tool name, llmTip, description, and path. After picking a tool, call get-tool-schema for parameters, then execute-tool.`,
553
676
  {
554
677
  query: z.string().describe(
555
- 'Natural-language query. Tokenized and BM25-ranked. E.g. "send email", "create calendar event", "list unread messages".'
678
+ 'Natural-language query. Tokenized and BM25-ranked. E.g. "send email", "download photo", "list unread messages".'
556
679
  ).optional(),
557
680
  category: z.string().describe(`Optional pre-filter by category: ${categoryNames}`).optional(),
558
681
  limit: z.number().describe("Maximum results (default: 10, max: 50)").optional()
@@ -571,7 +694,9 @@ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode =
571
694
  const ranked = scoreDiscoveryQuery(query, searchIndex);
572
695
  orderedNames = ranked.map((r) => r.id).filter(categoryFilter);
573
696
  } else {
574
- orderedNames = [...toolsRegistry.keys()].filter(categoryFilter);
697
+ orderedNames = [...toolsRegistry.keys(), ...utilityTools.map((u) => u.name)].filter(
698
+ categoryFilter
699
+ );
575
700
  }
576
701
  const tools = orderedNames.slice(0, maxLimit).map(toResultEntry).filter(Boolean);
577
702
  return {
@@ -581,7 +706,7 @@ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode =
581
706
  text: JSON.stringify(
582
707
  {
583
708
  found: tools.length,
584
- total: toolsRegistry.size,
709
+ total: totalCount,
585
710
  tools,
586
711
  tip: "Call get-tool-schema(tool_name) to see parameters before invoking execute-tool."
587
712
  },
@@ -606,23 +731,30 @@ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode =
606
731
  },
607
732
  async ({ tool_name }) => {
608
733
  const entry = toolsRegistry.get(tool_name);
609
- if (!entry) {
734
+ if (entry) {
735
+ const schema = describeToolSchema(entry.tool, entry.config?.llmTip);
610
736
  return {
611
- content: [
612
- {
613
- type: "text",
614
- text: JSON.stringify({
615
- error: `Tool not found: ${tool_name}`,
616
- tip: "Use search-tools to find available tools."
617
- })
618
- }
619
- ],
620
- isError: true
737
+ content: [{ type: "text", text: JSON.stringify(schema, null, 2) }]
738
+ };
739
+ }
740
+ const utility = utilityByName.get(tool_name);
741
+ if (utility) {
742
+ const schema = describeUtilityToolSchema(utility, utilityCtx);
743
+ return {
744
+ content: [{ type: "text", text: JSON.stringify(schema, null, 2) }]
621
745
  };
622
746
  }
623
- const schema = describeToolSchema(entry.tool, entry.config?.llmTip);
624
747
  return {
625
- content: [{ type: "text", text: JSON.stringify(schema, null, 2) }]
748
+ content: [
749
+ {
750
+ type: "text",
751
+ text: JSON.stringify({
752
+ error: `Tool not found: ${tool_name}`,
753
+ tip: "Use search-tools to find available tools."
754
+ })
755
+ }
756
+ ],
757
+ isError: true
626
758
  };
627
759
  }
628
760
  );
@@ -643,25 +775,36 @@ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode =
643
775
  },
644
776
  async ({ tool_name, parameters = {} }) => {
645
777
  const toolData = toolsRegistry.get(tool_name);
646
- if (!toolData) {
647
- return {
648
- content: [
649
- {
650
- type: "text",
651
- text: JSON.stringify({
652
- error: `Tool not found: ${tool_name}`,
653
- tip: "Use search-tools to find available tools."
654
- })
655
- }
656
- ],
657
- isError: true
658
- };
778
+ if (toolData) {
779
+ return executeGraphTool(
780
+ toolData.tool,
781
+ toolData.config,
782
+ graphClient,
783
+ parameters,
784
+ authManager
785
+ );
786
+ }
787
+ const utility = utilityByName.get(tool_name);
788
+ if (utility) {
789
+ return utility.execute(parameters, utilityCtx);
659
790
  }
660
- return executeGraphTool(toolData.tool, toolData.config, graphClient, parameters, authManager);
791
+ return {
792
+ content: [
793
+ {
794
+ type: "text",
795
+ text: JSON.stringify({
796
+ error: `Tool not found: ${tool_name}`,
797
+ tip: "Use search-tools to find available tools."
798
+ })
799
+ }
800
+ ],
801
+ isError: true
802
+ };
661
803
  }
662
804
  );
663
805
  }
664
806
  export {
807
+ UTILITY_TOOLS,
665
808
  buildDiscoverySearchIndex,
666
809
  buildToolsRegistry,
667
810
  registerDiscoveryTools,
@@ -30,6 +30,29 @@ function describeToolSchema(tool, llmTip) {
30
30
  parameters: params
31
31
  };
32
32
  }
33
+ function describeUtilityToolSchema(utility, ctx) {
34
+ const schemaMap = utility.buildSchema(ctx);
35
+ const params = Object.entries(schemaMap).map(([name, zodSchema]) => {
36
+ const { inner, optional } = unwrapOptional(zodSchema);
37
+ const jsonSchema = zodToJsonSchema(inner, { target: "jsonSchema7", $refStrategy: "none" });
38
+ const { $schema: _s, ...schema } = jsonSchema;
39
+ return {
40
+ name,
41
+ in: "Query",
42
+ required: !optional,
43
+ description: zodSchema.description,
44
+ schema
45
+ };
46
+ });
47
+ return {
48
+ name: utility.name,
49
+ method: utility.method,
50
+ path: utility.path,
51
+ description: utility.description,
52
+ parameters: params
53
+ };
54
+ }
33
55
  export {
34
- describeToolSchema
56
+ describeToolSchema,
57
+ describeUtilityToolSchema
35
58
  };
@@ -5,7 +5,8 @@ function buildGeneralMcpInstructions(opts) {
5
5
  "Mail and message $search uses KQL; the $search query parameter value must be double-quoted per Graph (see search-query-parameter in Microsoft Graph docs).",
6
6
  "When you need an organizational user or recipient address, resolve it with list-users (or another directory tool); do not invent SMTP addresses.",
7
7
  "Directory $search on collections such as /users or /groups requires ConsistencyLevel: eventual when the tool exposes that header.",
8
- "Teams chat and channel messages: prefer HTML contentType in the body; plain text is often mangled by Graph."
8
+ "Teams chat and channel messages: prefer HTML contentType in the body; plain text is often mangled by Graph.",
9
+ "Files / binary content: use download-bytes for any binary read (drive file content, mail attachments, profile photos, Teams hosted content, meeting recordings); pass it a Graph path or an absolute @microsoft.graph.downloadUrl from a metadata response. For uploads, upload-file-content takes a base64 string body up to 4MB; use create-upload-session above that."
9
10
  ];
10
11
  if (opts.readOnly) parts.push("This server is read-only; write operations are disabled.");
11
12
  if (opts.multiAccount)
package/dist/server.js CHANGED
@@ -74,7 +74,8 @@ class MicrosoftGraphServer {
74
74
  this.options.readOnly,
75
75
  this.options.orgMode,
76
76
  this.authManager,
77
- this.multiAccount
77
+ this.multiAccount,
78
+ this.accountNames
78
79
  );
79
80
  } else {
80
81
  registerGraphTools(
@@ -1,7 +1,7 @@
1
1
  const TOOL_CATEGORIES = {
2
2
  mail: {
3
3
  name: "mail",
4
- pattern: /mail|attachment|draft/i,
4
+ pattern: /mail|attachment|draft|download-bytes/i,
5
5
  description: "Email operations (read, send, manage folders, attachments)"
6
6
  },
7
7
  calendar: {
@@ -16,12 +16,12 @@ const TOOL_CATEGORIES = {
16
16
  },
17
17
  personal: {
18
18
  name: "personal",
19
- pattern: /mail|calendar|drive|contact|todo|onenote|attachment|draft|event|file|folder|search|query/i,
19
+ pattern: /mail|calendar|drive|contact|todo|onenote|attachment|draft|event|file|folder|search|query|download-bytes|parse-teams-url/i,
20
20
  description: "Personal productivity tools (mail, calendar, files, contacts, tasks, notes, search)"
21
21
  },
22
22
  work: {
23
23
  name: "work",
24
- pattern: /team|channel|chat|sharepoint|planner|site|list|shared|search|query/i,
24
+ pattern: /team|channel|chat|sharepoint|planner|site|list|shared|search|query|download-bytes/i,
25
25
  description: "Organization/work tools (Teams, SharePoint, shared mailboxes, search)",
26
26
  requiresOrgMode: true
27
27
  },
@@ -52,7 +52,7 @@ const TOOL_CATEGORIES = {
52
52
  },
53
53
  users: {
54
54
  name: "users",
55
- pattern: /user|list-users/i,
55
+ pattern: /user|list-users|download-bytes/i,
56
56
  description: "User directory access",
57
57
  requiresOrgMode: true
58
58
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
- "version": "0.95.0",
3
+ "version": "0.97.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",