@softeria/ms-365-mcp-server 0.154.3 → 0.156.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
@@ -105,6 +105,20 @@ Email (Outlook), Calendar, OneDrive Files, Excel, OneNote, To Do Tasks, Planner,
105
105
 
106
106
  Teams & Chats, Online Meetings, Transcripts & Recordings, Attendance Reports, SharePoint Sites & Lists, Shared Mailboxes & Calendars, User Management, Presence, Virtual Events
107
107
 
108
+ Custom Teams emojis are available in organization mode through `list-custom-emojis`
109
+ and `create-custom-emoji` (`teams` and `work` presets). These use the Microsoft Graph
110
+ beta API and request the delegated permissions `TeamworkCustomEmoji.Read` and
111
+ `TeamworkCustomEmoji.Create`, respectively. Read-only mode exposes only the list tool.
112
+ Existing deployments may need consent for these new scopes and reauthentication;
113
+ adding tool support does not upgrade an already-issued token.
114
+
115
+ Listing returns base64 image content, so use a small `top` and `filter` to keep
116
+ responses manageable. To create an emoji, pass `body: { displayName, contentBytes }`
117
+ with the exact approved name and base64 PNG/GIF file bytes. See Microsoft's
118
+ [list](https://learn.microsoft.com/en-us/graph/api/teamworkmessaging-list-customemojis?view=graph-rest-beta)
119
+ and [create](https://learn.microsoft.com/en-us/graph/api/teamworkmessaging-post-customemojis?view=graph-rest-beta)
120
+ contracts. These tools do not post messages or reactions.
121
+
108
122
  ### Required Graph API Permissions
109
123
 
110
124
  Permissions are requested dynamically based on which tools are enabled. Use `--list-permissions` to see the exact permissions for your configuration:
@@ -596,7 +610,15 @@ When running as an MCP server, the following options can be used:
596
610
  -v Enable verbose logging
597
611
  --read-only Start server in read-only mode, disabling write operations
598
612
  --http [port] Use Streamable HTTP transport instead of stdio (optionally specify port, default: 3000)
599
- Starts Express.js server with MCP endpoint at /mcp
613
+ Starts Express.js server with MCP endpoint at /mcp. Bound to a loopback host
614
+ (e.g. --http 127.0.0.1:3000 or --http [::1]:3000) with no --public-url, it
615
+ rejects requests whose Host or Origin is not localhost (not applied to the
616
+ --attachment-port listener)
617
+ --http-local-file-tools Register download-bytes-to-file over HTTP. Anyone who can reach the port
618
+ can write files as the server's user (without a valid token, only an empty
619
+ file that is removed again), so enable it only on a single-user machine.
620
+ Refused unless --http binds a loopback host with no --public-url and no
621
+ --trust-proxy-auth
600
622
  --enable-auth-tools Enable login/logout tools when using HTTP mode (disabled by default in HTTP mode)
601
623
  --enable-attachment-urls Let get-download-url mint a server-served URL for byte resources Graph
602
624
  exposes no pre-authenticated URL for (see "Server-Minted Attachment URLs")
@@ -634,6 +656,7 @@ Environment variables:
634
656
  - `MS365_MCP_TRUST_PROXY_HOPS=<n>`: Number of trusted reverse-proxy hops in HTTP mode (default `1`). Accurate per-IP rate limiting depends on this matching your deployment — set to the number of proxies in front of the server, `0` to use the raw socket peer IP, or a comma-separated subnet list
635
657
  - `MS365_MCP_ATTACHMENT_PORT=<port>`: Serve the attachment route on its own listener on this port (alternative to --attachment-port; requires `--enable-attachment-urls`)
636
658
  - `MS365_MCP_ATTACHMENT_HOST=<host>`: Interface the `MS365_MCP_ATTACHMENT_PORT` listener binds (alternative to --attachment-host; requires `--attachment-port`). Defaults to the host `--http` bound — which for a wildcard `--http` means both ports answer everywhere and the port split isolates nothing. See "Splitting the attachment listener"
659
+ - `MS365_MCP_HTTP_LOCAL_FILE_TOOLS=true|1`: Register download-bytes-to-file over HTTP (alternative to --http-local-file-tools; same restrictions)
637
660
  - `MS365_MCP_CLOUD_TYPE=global|china`: Microsoft cloud environment (alternative to --cloud flag)
638
661
  - `LOG_LEVEL`: Set logging level (default: 'info')
639
662
  - `SILENT=true|1`: Disable console output
package/dist/cli.js CHANGED
@@ -69,6 +69,9 @@ program.name("ms-365-mcp-server").description("Microsoft 365 MCP Server").versio
69
69
  ).option(
70
70
  "--trust-proxy-auth",
71
71
  "In HTTP mode, skip the built-in Bearer-token check on /mcp and ignore any forwarded Authorization header. All callers share the locally cached MSAL identity (same path stdio mode uses). Use only when an upstream reverse proxy has already authenticated the caller."
72
+ ).option(
73
+ "--http-local-file-tools",
74
+ "In HTTP mode, also register download-bytes-to-file, which writes files on the server as the server's user. Anyone who can reach the port can use it, so enable only on a single-user machine. Refused unless --http binds a loopback host with no --public-url and no --trust-proxy-auth."
72
75
  ).option(
73
76
  "--allow-unauthenticated-discovery",
74
77
  "In HTTP mode, allow MCP discovery requests (initialize, tools/list, prompts/list, resources/list, ping) without a bearer token, so a gateway can enumerate the tool catalog before any user has authenticated. Non-discovery requests (e.g. tools/call) still require a token. Off by default."
@@ -204,6 +207,9 @@ function parseArgs() {
204
207
  if (process.env.MS365_MCP_TRUST_PROXY_AUTH === "true" || process.env.MS365_MCP_TRUST_PROXY_AUTH === "1") {
205
208
  options.trustProxyAuth = true;
206
209
  }
210
+ if (process.env.MS365_MCP_HTTP_LOCAL_FILE_TOOLS === "true" || process.env.MS365_MCP_HTTP_LOCAL_FILE_TOOLS === "1") {
211
+ options.httpLocalFileTools = true;
212
+ }
207
213
  if (process.env.MS365_MCP_ALLOW_UNAUTHENTICATED_DISCOVERY === "true" || process.env.MS365_MCP_ALLOW_UNAUTHENTICATED_DISCOVERY === "1") {
208
214
  options.allowUnauthenticatedDiscovery = true;
209
215
  }
@@ -1659,6 +1659,39 @@
1659
1659
  "presets": ["teams", "teams-write", "work"],
1660
1660
  "workScopes": ["Team.ReadBasic.All"]
1661
1661
  },
1662
+ {
1663
+ "pathPattern": "/teamwork/messaging/customEmojis",
1664
+ "method": "get",
1665
+ "toolName": "list-custom-emojis",
1666
+ "apiVersion": "beta",
1667
+ "presets": ["teams", "work"],
1668
+ "workScopes": ["TeamworkCustomEmoji.Read"],
1669
+ "llmTip": "Lists the organization's custom Teams emojis, including displayName, contentBytes (base64 PNG/GIF), createdBy and createdDateTime. The documented query options are $top and $filter. Image bytes can make responses large: start with a small $top and narrow with $filter; use fetchAllPages only for an explicitly requested export. displayName is the unique key, not an id. Work/school accounts only. Microsoft Graph beta API: subject to change."
1670
+ },
1671
+ {
1672
+ "pathPattern": "/teamwork/messaging/customEmojis",
1673
+ "method": "post",
1674
+ "toolName": "create-custom-emoji",
1675
+ "apiVersion": "beta",
1676
+ "presets": ["teams", "work"],
1677
+ "workScopes": ["TeamworkCustomEmoji.Create"],
1678
+ "requestBodySchema": {
1679
+ "type": "object",
1680
+ "required": ["displayName", "contentBytes"],
1681
+ "properties": {
1682
+ "displayName": {
1683
+ "type": "string",
1684
+ "description": "Exact unique custom emoji name, without surrounding colons. Must not conflict with an existing emoji name."
1685
+ },
1686
+ "contentBytes": {
1687
+ "type": "string",
1688
+ "description": "Base64-encoded PNG or GIF file content; do not include a data-URL prefix."
1689
+ }
1690
+ },
1691
+ "additionalProperties": false
1692
+ },
1693
+ "llmTip": "Uploads a custom Teams emoji for the organization using body: { displayName, contentBytes }. Only PNG and GIF are supported; pass the complete image file as base64. Confirm the exact name and image with the user before creating it. Returns the created emoji, including its image bytes; use excludeResponse=true when only success/failure is needed. Work/school accounts only. Microsoft Graph beta API: subject to change."
1694
+ },
1662
1695
  {
1663
1696
  "pathPattern": "/teams/{team-id}",
1664
1697
  "method": "get",
@@ -742,6 +742,36 @@ const microsoft_graph_plannerTaskChatMessageCollectionResponse = z.object({
742
742
  "@odata.nextLink": z.string().nullable(),
743
743
  value: z.array(microsoft_graph_plannerTaskChatMessage)
744
744
  }).partial().passthrough();
745
+ const microsoft_graph_customEmojiFromIdentitySet = z.object({
746
+ application: microsoft_graph_identity.optional(),
747
+ device: microsoft_graph_identity.optional(),
748
+ user: microsoft_graph_identity.optional()
749
+ }).passthrough();
750
+ const microsoft_graph_teamworkCustomEmoji = z.object({
751
+ contentBytes: z.string().describe(
752
+ "The base64-encoded image content of the emoji. Supported formats include PNG and GIF."
753
+ ).nullish(),
754
+ createdBy: microsoft_graph_customEmojiFromIdentitySet.optional(),
755
+ createdDateTime: z.string().regex(
756
+ /^[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])$/
757
+ ).datetime({ offset: true }).describe(
758
+ "The date and time when the emoji was created. The timestamp type represents date and time information using ISO 8601 format and is always in UTC. For example, midnight UTC on Jan 1, 2024, is 2024-01-01T00:00:00Z."
759
+ ).optional(),
760
+ displayName: z.string().describe(
761
+ "The unique display name of the custom emoji. Key. Must be unique and must not conflict with existing emoji names."
762
+ ).optional()
763
+ }).passthrough();
764
+ const microsoft_graph_teamworkCustomEmojiCollectionResponse = z.object({
765
+ "@odata.count": z.number().int().nullable(),
766
+ "@odata.nextLink": z.string().nullable(),
767
+ value: z.array(microsoft_graph_teamworkCustomEmoji)
768
+ }).partial().passthrough();
769
+ const create_custom_emoji_Body = z.object({
770
+ displayName: z.string().describe(
771
+ "Exact unique custom emoji name, without surrounding colons. Must not conflict with an existing emoji name."
772
+ ),
773
+ contentBytes: z.string().describe("Base64-encoded PNG or GIF file content; do not include a data-URL prefix.")
774
+ }).passthrough();
745
775
  const schemas = {
746
776
  microsoft_graph_allowedAudiences,
747
777
  microsoft_graph_identity,
@@ -800,7 +830,11 @@ const schemas = {
800
830
  microsoft_graph_plannerTaskChatReactionEvent,
801
831
  microsoft_graph_plannerTaskChatReaction,
802
832
  microsoft_graph_plannerTaskChatMessage,
803
- microsoft_graph_plannerTaskChatMessageCollectionResponse
833
+ microsoft_graph_plannerTaskChatMessageCollectionResponse,
834
+ microsoft_graph_customEmojiFromIdentitySet,
835
+ microsoft_graph_teamworkCustomEmoji,
836
+ microsoft_graph_teamworkCustomEmojiCollectionResponse,
837
+ create_custom_emoji_Body
804
838
  };
805
839
  const endpoints = makeApi([
806
840
  {
@@ -903,6 +937,72 @@ const endpoints = makeApi([
903
937
  }
904
938
  ],
905
939
  response: z.void()
940
+ },
941
+ {
942
+ method: "get",
943
+ path: "/teamwork/messaging/customEmojis",
944
+ alias: "list-custom-emojis",
945
+ description: `Get a list of custom emojis available in the teamwork messaging of the organization.`,
946
+ requestFormat: "json",
947
+ parameters: [
948
+ {
949
+ name: "$top",
950
+ type: "Query",
951
+ schema: z.number().int().gte(0).describe("Show only the first n items").optional()
952
+ },
953
+ {
954
+ name: "$skip",
955
+ type: "Query",
956
+ schema: z.number().int().gte(0).describe("Skip the first n items").optional()
957
+ },
958
+ {
959
+ name: "$search",
960
+ type: "Query",
961
+ schema: z.string().describe("Search items by search phrases").optional()
962
+ },
963
+ {
964
+ name: "$filter",
965
+ type: "Query",
966
+ schema: z.string().describe("Filter items by property values").optional()
967
+ },
968
+ {
969
+ name: "$count",
970
+ type: "Query",
971
+ schema: z.boolean().describe("Include count of items").optional()
972
+ },
973
+ {
974
+ name: "$orderby",
975
+ type: "Query",
976
+ schema: z.array(z.string()).describe("Order items by property values").optional()
977
+ },
978
+ {
979
+ name: "$select",
980
+ type: "Query",
981
+ schema: z.array(z.string()).describe("Select properties to be returned").optional()
982
+ },
983
+ {
984
+ name: "$expand",
985
+ type: "Query",
986
+ schema: z.array(z.string()).describe("Expand related entities").optional()
987
+ }
988
+ ],
989
+ response: microsoft_graph_teamworkCustomEmojiCollectionResponse
990
+ },
991
+ {
992
+ method: "post",
993
+ path: "/teamwork/messaging/customEmojis",
994
+ alias: "create-custom-emoji",
995
+ description: `Create a new custom emoji in the teamwork messaging of the organization, which adds the custom emoji to Teams for the tenant. The emoji image is provided as base64-encoded content bytes.`,
996
+ requestFormat: "json",
997
+ parameters: [
998
+ {
999
+ name: "body",
1000
+ description: `New navigation property`,
1001
+ type: "Body",
1002
+ schema: create_custom_emoji_Body
1003
+ }
1004
+ ],
1005
+ response: microsoft_graph_teamworkCustomEmoji
906
1006
  }
907
1007
  ]);
908
1008
  const api = new Zodios(endpoints);
@@ -699,7 +699,7 @@ const UTILITY_TOOLS = [
699
699
  // description at ~40 tokens, so the OneDrive/SharePoint guidance below
700
700
  // sits past the cap. That keeps the hint for the reading LLM while letting
701
701
  // get-download-url own the high-signal "drive"/"sharepoint" search terms.
702
- description: "Write authenticated Microsoft Graph byte content to a local file on the server, returning { path, contentType, bytesWritten } instead of base64. The only out-of-band way to save mail attachments and meeting recordings, whose bytes are exposed solely through authenticated endpoints. Also handles profile photos and Teams hosted content. Writes to an absolute outputPath and never overwrites an existing file. stdio mode only: not available over HTTP. For OneDrive or SharePoint file content, get-download-url is preferred \u2014 it returns a pre-authenticated URL for fully out-of-band download without the server fetching the bytes.",
702
+ description: "Write authenticated Microsoft Graph byte content to a local file on the server, returning { path, contentType, bytesWritten } instead of base64. The only out-of-band way to save mail attachments and meeting recordings, whose bytes are exposed solely through authenticated endpoints. Also handles profile photos and Teams hosted content. Writes to an absolute outputPath and never overwrites an existing file. stdio mode, or HTTP with --http-local-file-tools. For OneDrive or SharePoint file content, get-download-url is preferred \u2014 it returns a pre-authenticated URL for fully out-of-band download without the server fetching the bytes.",
703
703
  readOnlyHint: true,
704
704
  openWorldHint: true,
705
705
  stdioOnly: true,
@@ -1671,7 +1671,7 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
1671
1671
  }
1672
1672
  if (isFetchAllPagesApplicable(tool)) {
1673
1673
  const maxPages = getMaxPages();
1674
- paramSchema["fetchAllPages"] = z.boolean().describe(getFetchAllPagesParamDescription(maxPages)).optional();
1674
+ paramSchema["fetchAllPages"] = z.boolean().describe(getFetchAllPagesParamDescription(maxPages, tool.alias)).optional();
1675
1675
  }
1676
1676
  if (isSkiptokenApplicable(tool, Object.keys(paramSchema))) {
1677
1677
  paramSchema["skiptoken"] = z.string().describe(SKIPTOKEN_PARAM_DESCRIPTION).optional();
@@ -55,8 +55,9 @@ function getAccountParamDescription(accountNames) {
55
55
  const accountHint = accountNames.length > 0 ? `Known accounts: ${accountNames.join(", ")}. ` : "";
56
56
  return `${accountHint}Microsoft account email to use for this request. Required when multiple accounts are configured. Use the list-accounts tool to discover all currently available accounts.`;
57
57
  }
58
- function getFetchAllPagesParamDescription(maxPages) {
59
- return `Follow @odata.nextLink and merge up to ${maxPages} pages into one response. Can return enormous payloads\u2014only when the user explicitly needs a full export. Prefer a small $top first, then paginate or narrow with $filter/$search.`;
58
+ function getFetchAllPagesParamDescription(maxPages, toolAlias) {
59
+ const narrowingOptions = toolAlias === "list-custom-emojis" ? "$filter" : "$filter/$search";
60
+ return `Follow @odata.nextLink and merge up to ${maxPages} pages into one response. Can return enormous payloads\u2014only when the user explicitly needs a full export. Prefer a small $top first, then paginate or narrow with ${narrowingOptions}.`;
60
61
  }
61
62
  function getODataParamDescription(bareName) {
62
63
  switch (bareName) {
@@ -1,9 +1,16 @@
1
1
  import { z } from "zod";
2
2
  import { getODataParamDescription, shouldOmitTopParam } from "./param-descriptions.js";
3
3
  const TEAM_LIST_TOOLS = /* @__PURE__ */ new Set(["list-joined-teams", "list-my-associated-teams"]);
4
+ const CUSTOM_EMOJI_QUERY_DESCRIPTIONS = {
5
+ top: "Number of custom emojis to return in one page. Each emoji includes base64 image content; use a small page size to keep the response manageable.",
6
+ filter: "OData filter expression for custom emojis, forwarded to Microsoft Graph. Filter support is determined by the beta API."
7
+ };
4
8
  function queryParameterSchema(toolName, name, providerSchema) {
5
9
  const bareName = name.replace(/^\$/, "").toLowerCase();
6
10
  if (TEAM_LIST_TOOLS.has(toolName)) return void 0;
11
+ if (toolName === "list-custom-emojis" && !["top", "filter", "skiptoken"].includes(bareName)) {
12
+ return void 0;
13
+ }
7
14
  if (bareName === "top" && shouldOmitTopParam(toolName)) return void 0;
8
15
  const source = providerSchema instanceof z.ZodOptional ? providerSchema.unwrap() : providerSchema;
9
16
  let schema = source;
@@ -25,7 +32,7 @@ function queryParameterSchema(toolName, name, providerSchema) {
25
32
  break;
26
33
  }
27
34
  if (providerSchema.isOptional()) schema = schema.optional();
28
- const description = getODataParamDescription(bareName);
35
+ const description = toolName === "list-custom-emojis" && CUSTOM_EMOJI_QUERY_DESCRIPTIONS[bareName] || getODataParamDescription(bareName);
29
36
  return description ? schema.describe(description) : schema;
30
37
  }
31
38
  export {
@@ -53,7 +53,7 @@ function describeToolSchema(tool, config, ctx = {}) {
53
53
  name: "fetchAllPages",
54
54
  in: "Query",
55
55
  required: false,
56
- description: getFetchAllPagesParamDescription(getMaxPages()),
56
+ description: getFetchAllPagesParamDescription(getMaxPages(), tool.alias),
57
57
  schema: { type: "boolean" }
58
58
  });
59
59
  }
@@ -6,7 +6,7 @@ function buildGeneralMcpInstructions(opts) {
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
8
  "Teams chat and channel messages: prefer HTML contentType in the body; plain text is often mangled by Graph.",
9
- "Files / binary content: for large drive/SharePoint file content, prefer get-download-url to resolve a pre-authenticated URL for out-of-band download. Use download-bytes for authenticated byte reads such as mail attachments, profile photos, Teams hosted content, and meeting recordings. In stdio mode, download-bytes-to-file writes those same authenticated bytes straight to a local absolute path instead of returning base64 \u2014 the only out-of-band option for large mail attachments and meeting recordings, which get-download-url cannot handle. These tools take relative Microsoft Graph paths, not absolute URLs. For uploads, upload-file-content takes a base64 string body (Graph allows 250MB, but the whole string passes through the agent context and a truncated one is written without error); use create-upload-session for anything but small files."
9
+ "Files / binary content: for large drive/SharePoint file content, prefer get-download-url to resolve a pre-authenticated URL for out-of-band download. Use download-bytes for authenticated byte reads such as mail attachments, profile photos, Teams hosted content, and meeting recordings. In stdio mode (or HTTP with --http-local-file-tools), download-bytes-to-file writes those same authenticated bytes straight to a local absolute path instead of returning base64 \u2014 the only out-of-band option for large mail attachments and meeting recordings, which get-download-url cannot handle. These tools take relative Microsoft Graph paths, not absolute URLs. For uploads, upload-file-content takes a base64 string body (Graph allows 250MB, but the whole string passes through the agent context and a truncated one is written without error); use create-upload-session for anything but small files."
10
10
  ];
11
11
  if (opts.readOnly) parts.push("This server is read-only; write operations are disabled.");
12
12
  if (opts.multiAccount)
package/dist/server.js CHANGED
@@ -36,11 +36,40 @@ import { dumpError } from "./crash-logging.js";
36
36
  import crypto from "node:crypto";
37
37
  import { isIP, isIPv6 } from "node:net";
38
38
  import OboClient from "./obo-client.js";
39
+ import { hostHeaderValidation } from "@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js";
40
+ const LOOPBACK_HOSTNAMES = ["localhost", "127.0.0.1", "[::1]"];
41
+ function isLoopbackHost(host) {
42
+ if (!host) return false;
43
+ const bare = host.toLowerCase().replace(/^\[(.*)\]$/, "$1");
44
+ return bare === "localhost" || bare === "127.0.0.1" || bare === "::1";
45
+ }
46
+ function loopbackOriginValidation() {
47
+ return (req, res, next) => {
48
+ const origin = req.headers.origin;
49
+ if (origin === void 0) return next();
50
+ let hostname;
51
+ try {
52
+ hostname = new URL(origin).hostname;
53
+ } catch {
54
+ hostname = void 0;
55
+ }
56
+ if (hostname && LOOPBACK_HOSTNAMES.includes(hostname)) return next();
57
+ res.status(403).json({
58
+ jsonrpc: "2.0",
59
+ error: { code: -32e3, message: `Invalid Origin: ${origin}` },
60
+ id: null
61
+ });
62
+ };
63
+ }
39
64
  function parseHttpOption(httpOption) {
40
65
  if (typeof httpOption === "boolean") {
41
66
  return { host: void 0, port: 3e3 };
42
67
  }
43
68
  const httpString = httpOption.trim();
69
+ const bracketed = /^\[([^\]]+)\](?::(.*))?$/.exec(httpString);
70
+ if (bracketed) {
71
+ return { host: bracketed[1], port: parseInt(bracketed[2] ?? "") || 3e3 };
72
+ }
44
73
  if (httpString.includes(":")) {
45
74
  const [hostPart, portPart] = httpString.split(":");
46
75
  const host = hostPart || void 0;
@@ -131,6 +160,14 @@ class MicrosoftGraphServer {
131
160
  this.secrets = null;
132
161
  this.oboClient = null;
133
162
  }
163
+ isLoopbackOnlyHttp() {
164
+ if (!this.options.http) return false;
165
+ const publicUrl = this.options.publicUrl || process.env.MS365_MCP_PUBLIC_URL || this.options.baseUrl || process.env.MS365_MCP_BASE_URL;
166
+ return !publicUrl && isLoopbackHost(parseHttpOption(this.options.http).host);
167
+ }
168
+ hidesStdioOnlyTools() {
169
+ return Boolean(this.options.http) && !this.options.httpLocalFileTools;
170
+ }
134
171
  createMcpServer() {
135
172
  const server = new McpServer(
136
173
  {
@@ -161,7 +198,7 @@ class MicrosoftGraphServer {
161
198
  this.accountNames,
162
199
  this.options.enabledTools,
163
200
  this.options.allowedScopes,
164
- Boolean(this.options.http)
201
+ this.hidesStdioOnlyTools()
165
202
  );
166
203
  } else {
167
204
  registerGraphTools(
@@ -174,7 +211,7 @@ class MicrosoftGraphServer {
174
211
  this.multiAccount,
175
212
  this.accountNames,
176
213
  this.options.allowedScopes,
177
- Boolean(this.options.http)
214
+ this.hidesStdioOnlyTools()
178
215
  );
179
216
  }
180
217
  installToolSchemaRefNormalization(server);
@@ -202,6 +239,25 @@ class MicrosoftGraphServer {
202
239
  'Account routing disabled: requests use the OAuth bearer identity, so the "account" parameter is not injected into tool schemas'
203
240
  );
204
241
  }
242
+ if (this.options.httpLocalFileTools && this.options.http) {
243
+ if (!this.isLoopbackOnlyHttp()) {
244
+ throw new Error(
245
+ "--http-local-file-tools requires --http bound to a loopback host (localhost, 127.0.0.1 or [::1]) and no --public-url: anyone who can reach the port can write files as the server's user."
246
+ );
247
+ }
248
+ if (this.options.trustProxyAuth) {
249
+ throw new Error(
250
+ "--http-local-file-tools cannot be combined with --trust-proxy-auth: a proxy in front of the loopback port would let remote callers write files as the server's user."
251
+ );
252
+ }
253
+ logger.warn(
254
+ "--http-local-file-tools: download-bytes-to-file is registered over HTTP; anyone who can reach this port can write files as the server's user."
255
+ );
256
+ } else if (this.options.httpLocalFileTools) {
257
+ logger.warn(
258
+ "--http-local-file-tools has no effect in stdio mode, where download-bytes-to-file is always registered."
259
+ );
260
+ }
205
261
  if (this.options.obo) {
206
262
  if (!this.options.http) {
207
263
  throw new Error("--obo requires --http (On-Behalf-Of flow only works in HTTP mode).");
@@ -267,6 +323,10 @@ class MicrosoftGraphServer {
267
323
  if (this.options.http) {
268
324
  const { host, port } = parseHttpOption(this.options.http);
269
325
  const app = express();
326
+ if (this.isLoopbackOnlyHttp()) {
327
+ app.use(hostHeaderValidation(LOOPBACK_HOSTNAMES));
328
+ app.use(loopbackOriginValidation());
329
+ }
270
330
  const trustProxyEnv = process.env.MS365_MCP_TRUST_PROXY_HOPS;
271
331
  if (trustProxyEnv !== void 0 && trustProxyEnv !== "") {
272
332
  const asNum = Number(trustProxyEnv);
@@ -803,6 +863,9 @@ class MicrosoftGraphServer {
803
863
  var server_default = MicrosoftGraphServer;
804
864
  export {
805
865
  server_default as default,
866
+ isLoopbackHost,
867
+ loopbackOriginValidation,
806
868
  parseAttachmentHostOption,
807
- parseAttachmentPortOption
869
+ parseAttachmentPortOption,
870
+ parseHttpOption
808
871
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
3
  "mcpName": "io.github.Softeria/ms-365-mcp-server",
4
- "version": "0.154.3",
4
+ "version": "0.156.0",
5
5
  "description": " A Model Context Protocol (MCP) server for interacting with Microsoft 365 and Office services through the Graph API",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -1659,6 +1659,39 @@
1659
1659
  "presets": ["teams", "teams-write", "work"],
1660
1660
  "workScopes": ["Team.ReadBasic.All"]
1661
1661
  },
1662
+ {
1663
+ "pathPattern": "/teamwork/messaging/customEmojis",
1664
+ "method": "get",
1665
+ "toolName": "list-custom-emojis",
1666
+ "apiVersion": "beta",
1667
+ "presets": ["teams", "work"],
1668
+ "workScopes": ["TeamworkCustomEmoji.Read"],
1669
+ "llmTip": "Lists the organization's custom Teams emojis, including displayName, contentBytes (base64 PNG/GIF), createdBy and createdDateTime. The documented query options are $top and $filter. Image bytes can make responses large: start with a small $top and narrow with $filter; use fetchAllPages only for an explicitly requested export. displayName is the unique key, not an id. Work/school accounts only. Microsoft Graph beta API: subject to change."
1670
+ },
1671
+ {
1672
+ "pathPattern": "/teamwork/messaging/customEmojis",
1673
+ "method": "post",
1674
+ "toolName": "create-custom-emoji",
1675
+ "apiVersion": "beta",
1676
+ "presets": ["teams", "work"],
1677
+ "workScopes": ["TeamworkCustomEmoji.Create"],
1678
+ "requestBodySchema": {
1679
+ "type": "object",
1680
+ "required": ["displayName", "contentBytes"],
1681
+ "properties": {
1682
+ "displayName": {
1683
+ "type": "string",
1684
+ "description": "Exact unique custom emoji name, without surrounding colons. Must not conflict with an existing emoji name."
1685
+ },
1686
+ "contentBytes": {
1687
+ "type": "string",
1688
+ "description": "Base64-encoded PNG or GIF file content; do not include a data-URL prefix."
1689
+ }
1690
+ },
1691
+ "additionalProperties": false
1692
+ },
1693
+ "llmTip": "Uploads a custom Teams emoji for the organization using body: { displayName, contentBytes }. Only PNG and GIF are supported; pass the complete image file as base64. Confirm the exact name and image with the user before creating it. Returns the created emoji, including its image bytes; use excludeResponse=true when only success/failure is needed. Work/school accounts only. Microsoft Graph beta API: subject to change."
1694
+ },
1662
1695
  {
1663
1696
  "pathPattern": "/teams/{team-id}",
1664
1697
  "method": "get",