@softeria/ms-365-mcp-server 0.168.0 → 0.169.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 +41 -0
- package/dist/__tests__/graph-tools.test.js +5 -7
- package/dist/endpoints.json +1 -1
- package/dist/graph-tools.js +147 -8
- package/dist/lib/mail-text.js +293 -0
- package/dist/lib/param-descriptions.js +23 -9
- package/dist/lib/query-parameter-schema.js +2 -7
- package/dist/lib/tool-schema.js +1 -1
- package/dist/tool-categories.js +2 -1
- package/package.json +1 -1
- package/src/endpoints.json +1 -1
package/README.md
CHANGED
|
@@ -736,6 +736,7 @@ Environment variables:
|
|
|
736
736
|
- `MS365_MCP_BLOB_MAX_BYTES=<n>`: With blob output on, the largest file a download tool reads (positive integer, default `5242880`). The server stops reading past it and answers an error naming the limit.
|
|
737
737
|
- `MS365_MCP_BODY_FORMAT=html`: Return email bodies as HTML instead of plain text (default: text)
|
|
738
738
|
- `MS365_MCP_OBO_ALLOWED_TENANTS=<ids>` / `MS365_MCP_OBO_ALLOWED_USERS=<ids>`: With `--obo`, only callers whose token `tid` (tenant id) / `oid` (user object id) is listed may use the server; others get 403 before any On-Behalf-Of exchange. Comma or space separated; unset = everyone. The claims are read without verifying the signature, which Entra verifies during the exchange, so a token that lies about them still gets no Graph token. A token Entra rejects at the exchange (bad signature, expired, wrong audience, consent needed) gets 401 with a `WWW-Authenticate` challenge so the client signs in again
|
|
739
|
+
- `MS365_MCP_READ_MAIL_TEXT_MAX_CHARS=<n>`: Largest result `read-mail-text` returns, in characters (default `80000`). A larger result is refused whole, naming the largest messages, rather than cut (see Reading Mail as Clean Text below)
|
|
739
740
|
- `MS365_MCP_MESSAGE_SIGNOFF_PREFIX=<text>`: Signoff prepended to outgoing messages so recipients can tell they were agent-sent, e.g. `🤖`. Default: none. CLI equivalent: `--message-signoff-prefix <text>` (see Message Signoff below)
|
|
740
741
|
- `MS365_MCP_MESSAGE_SIGNOFF_SUFFIX=<text>`: Signoff appended to outgoing messages. Default: none. CLI equivalent: `--message-signoff-suffix <text>`. `--no-message-signoff` disables both (see Message Signoff below)
|
|
741
742
|
- `MS365_MCP_RATE_LIMIT_DISABLED=true|1`: Disable per-IP rate limiting in HTTP mode (default: enabled — 30 req/min on `/authorize`, `/token`, `/register`; 120 req/min on `/mcp`)
|
|
@@ -760,6 +761,46 @@ Environment variables:
|
|
|
760
761
|
- `MS365_MCP_EXPECTED_USERNAME`: Require local MSAL auth to use this Microsoft account username (case-insensitive; CLI flag takes precedence)
|
|
761
762
|
- `MS365_MCP_EXPECTED_HOME_ACCOUNT_ID`: Require local MSAL auth to use this exact MSAL homeAccountId (CLI flag takes precedence)
|
|
762
763
|
|
|
764
|
+
## Reading Mail as Clean Text
|
|
765
|
+
|
|
766
|
+
`read-mail-text` returns messages as text with the bulk junk removed. Graph's
|
|
767
|
+
plain-text body is still mostly not the message: links are wrapped in Safe Links
|
|
768
|
+
or tracking redirects several hundred characters long, marketing mail pads its
|
|
769
|
+
preheader with invisible characters, images become `[https://…png]` lines, and
|
|
770
|
+
replies carry the whole quoted thread. On a real mailbox, 20 inbox messages came
|
|
771
|
+
to about 280,000 characters through `list-mail-messages` and 70,000 through
|
|
772
|
+
`read-mail-text`.
|
|
773
|
+
|
|
774
|
+
The rule is: remove obvious junk, never cut or rewrite the message; anything
|
|
775
|
+
ambiguous is kept. It removes runs of invisible padding characters, image
|
|
776
|
+
placeholders (a bracketed address of an image file), and quoted reply history
|
|
777
|
+
from its header down (a dated `On … wrote:`, also `>`-quoted, an Outlook
|
|
778
|
+
`From:/Sent:` block, `-----Original Message-----`). Forwards keep the forwarded
|
|
779
|
+
content: a subject starting `FW:` / `Fwd:` or a localised prefix (`VS:`, `WG:`,
|
|
780
|
+
`TR:`, `RV:`, `I:`, `ENC:`, …, also after tags such as `[EXTERNAL]`), or history
|
|
781
|
+
directly under a `Forwarded message` / `Begin forwarded message:` marker. Links are kept:
|
|
782
|
+
Safe Links, Proofpoint URL Defense and Google redirects are unwrapped to the real
|
|
783
|
+
address, tracking parameters (`utm_*` and similar) are dropped, and only an
|
|
784
|
+
address still longer than 300 characters (a tracking redirect) is shortened to
|
|
785
|
+
`⟦link: domain⟧`. Line endings become `\n`, and the marker brackets are
|
|
786
|
+
reserved: a body's own `⟦ ⟧` appear as `[ ]`, so text cannot pose as a marker.
|
|
787
|
+
Everything else — signatures, footers, whitespace, repeated links — is left as
|
|
788
|
+
the message has it. `get-mail-message` returns the raw body.
|
|
789
|
+
|
|
790
|
+
- `messageIds` (up to 20, one `$batch`), or `folderId` with optional `top`
|
|
791
|
+
(max 20), `filter` or `search`.
|
|
792
|
+
- Each message is cut to `maxChars` characters, default 3,000 (marked
|
|
793
|
+
`truncated`): a page of 20 then fits the result limit, and a typical message
|
|
794
|
+
is whole. Ticket systems that repeat their history in every update put the
|
|
795
|
+
newest entry first, so the cut keeps it. Pass a larger `maxChars` (up to
|
|
796
|
+
300,000; a raw body beyond that is cleaned only that far) for the whole of a
|
|
797
|
+
long message; `get-mail-message` has the raw body.
|
|
798
|
+
- Returns `[{ id, from, to, cc, subject, received, hasAttachments, text, chars, truncated }]`;
|
|
799
|
+
an unknown id comes back as `{ id, error }`.
|
|
800
|
+
- A result over `MS365_MCP_READ_MAIL_TEXT_MAX_CHARS` (default 80,000) is refused
|
|
801
|
+
whole with the largest messages listed, so the agent narrows the query instead
|
|
802
|
+
of silently getting part of it.
|
|
803
|
+
|
|
763
804
|
## Server-Minted Attachment URLs
|
|
764
805
|
|
|
765
806
|
`get-download-url` returns Microsoft's own pre-authenticated `@microsoft.graph.downloadUrl`
|
|
@@ -1164,7 +1164,7 @@ describe("graph-tools", () => {
|
|
|
1164
1164
|
expect(request).toContain("$skiptoken=next-token");
|
|
1165
1165
|
});
|
|
1166
1166
|
it.each(["list-calendar-events-delta", "list-calendar-view-delta"])(
|
|
1167
|
-
"
|
|
1167
|
+
"rejects mixed-case top values before Graph dispatch for %s",
|
|
1168
1168
|
async (alias) => {
|
|
1169
1169
|
mockEndpoints.push(makeEndpoint({ alias }));
|
|
1170
1170
|
mockEndpointsJson = [makeConfig({ toolName: alias })];
|
|
@@ -1173,12 +1173,10 @@ describe("graph-tools", () => {
|
|
|
1173
1173
|
const { registerGraphTools } = await loadModule();
|
|
1174
1174
|
registerGraphTools(server, graphClient);
|
|
1175
1175
|
for (const key of ["top", "$top", "TOP", "$TOP", "$Top"]) {
|
|
1176
|
-
await server.tools.get(alias).handler({ [key]: 10 });
|
|
1177
|
-
|
|
1178
|
-
expect(graphClient.graphRequest).toHaveBeenCalledTimes(5);
|
|
1179
|
-
for (const call of graphClient.graphRequest.mock.calls) {
|
|
1180
|
-
expect(JSON.stringify(call)).not.toContain("$top");
|
|
1176
|
+
const result = await server.tools.get(alias).handler({ [key]: 10 });
|
|
1177
|
+
expect(result.isError).toBe(true);
|
|
1181
1178
|
}
|
|
1179
|
+
expect(graphClient.graphRequest).not.toHaveBeenCalled();
|
|
1182
1180
|
}
|
|
1183
1181
|
);
|
|
1184
1182
|
it.each(["list-joined-teams", "list-my-associated-teams"])(
|
|
@@ -1212,7 +1210,7 @@ describe("graph-tools", () => {
|
|
|
1212
1210
|
expect(graphClient.graphRequest).not.toHaveBeenCalled();
|
|
1213
1211
|
});
|
|
1214
1212
|
it("sends a calendar field array as comma-separated text in one Graph request", async () => {
|
|
1215
|
-
const alias = "list-calendar-events
|
|
1213
|
+
const alias = "list-calendar-events";
|
|
1216
1214
|
mockEndpoints.push(makeEndpoint({ alias }));
|
|
1217
1215
|
mockEndpointsJson = [makeConfig({ toolName: alias })];
|
|
1218
1216
|
const graphClient = createMockGraphClient();
|
package/dist/endpoints.json
CHANGED
|
@@ -2543,7 +2543,7 @@
|
|
|
2543
2543
|
"toolName": "list-calendar-events-delta",
|
|
2544
2544
|
"presets": ["calendar", "outlook", "personal"],
|
|
2545
2545
|
"scopes": ["Calendars.Read"],
|
|
2546
|
-
"llmTip": "Incremental sync of events across the default calendar. First call returns all events plus @odata.deltaLink. Subsequent calls with that link return only additions/updates/removals.
|
|
2546
|
+
"llmTip": "Incremental sync of events across the default calendar. First call returns all events plus @odata.deltaLink. Subsequent calls with that link return only additions/updates/removals. Deltas expire after ~30 days — start over if the server returns 410 Gone. For a time-bounded view with delta semantics, use list-calendar-view-delta instead."
|
|
2547
2547
|
},
|
|
2548
2548
|
{
|
|
2549
2549
|
"pathPattern": "/me/calendarView/delta()",
|
package/dist/graph-tools.js
CHANGED
|
@@ -38,10 +38,8 @@ import {
|
|
|
38
38
|
isSkiptokenApplicable,
|
|
39
39
|
paginationAllowed,
|
|
40
40
|
positiveIntFromEnv,
|
|
41
|
-
shouldOmitTopParam,
|
|
42
41
|
SKIPTOKEN_PARAM_DESCRIPTION,
|
|
43
|
-
TIMEZONE_PARAM_DESCRIPTION
|
|
44
|
-
TOP_UNSUPPORTED_DELTA_TOOLS
|
|
42
|
+
TIMEZONE_PARAM_DESCRIPTION
|
|
45
43
|
} from "./lib/param-descriptions.js";
|
|
46
44
|
import { describePathParam } from "./lib/path-params.js";
|
|
47
45
|
import { queryParameterSchema } from "./lib/query-parameter-schema.js";
|
|
@@ -52,6 +50,18 @@ import {
|
|
|
52
50
|
projectSelectedFields
|
|
53
51
|
} from "./lib/select-projection.js";
|
|
54
52
|
import { parseTeamsUrl } from "./lib/teams-url-parser.js";
|
|
53
|
+
import {
|
|
54
|
+
MAIL_TEXT_DEFAULT_MAX_RESPONSE_CHARS,
|
|
55
|
+
MAIL_TEXT_DEFAULT_MAX_CHARS,
|
|
56
|
+
MAIL_TEXT_MAX_INPUT_CHARS,
|
|
57
|
+
MAIL_TEXT_MAX_IDS,
|
|
58
|
+
MAIL_TEXT_MAX_TOP,
|
|
59
|
+
MAIL_TEXT_SELECT,
|
|
60
|
+
PREFER_TEXT_BODY,
|
|
61
|
+
oversizeError,
|
|
62
|
+
toMailTextEntry,
|
|
63
|
+
validateMailTextArgs
|
|
64
|
+
} from "./lib/mail-text.js";
|
|
55
65
|
import { describeToolSchema, describeUtilityToolSchema } from "./lib/tool-schema.js";
|
|
56
66
|
import {
|
|
57
67
|
restrictUserFieldQuery,
|
|
@@ -921,6 +931,139 @@ const UTILITY_TOOLS = [
|
|
|
921
931
|
}
|
|
922
932
|
}
|
|
923
933
|
},
|
|
934
|
+
{
|
|
935
|
+
name: "read-mail-text",
|
|
936
|
+
method: "GET",
|
|
937
|
+
path: "tool:read-mail-text",
|
|
938
|
+
searchKeywords: "triage inbox skim many emails at once without quoted history signature footer tracking links",
|
|
939
|
+
description: "Triage view of up to 20 mail messages at once, as text with the bulk junk removed: invisible padding, image placeholders and quoted reply history (forwards keep theirs). Links are kept: Safe Links, Proofpoint and Google redirects are unwrapped to the real address and tracking parameters (utm_* and similar) dropped; only an address still over 300 characters (a tracking redirect) is shortened to \u27E6link: domain\u27E7. Everything else (signatures, footers, whitespace) is left as the message has it; line endings become \\n and \u27E6 \u27E7 in the body itself appear as [ ]. Use get-mail-message for the raw body or the quoted thread. Give messageIds (up to 20), or folderId with optional top and filter or search for the newest messages in a folder. Each message is cut to maxChars characters (default 3,000; marked truncated: true): pass a larger maxChars, up to 300,000, for the whole of a long message; a body over 300,000 characters is cleaned only that far. If the whole result would exceed the server limit (MS365_MCP_READ_MAIL_TEXT_MAX_CHARS, default 80000) the call fails, listing the largest messages, instead of returning part of it. Returns [{ id, from, to, cc, subject, received, hasAttachments, text, chars, truncated }].",
|
|
940
|
+
readOnlyHint: true,
|
|
941
|
+
openWorldHint: true,
|
|
942
|
+
buildSchema: (ctx) => {
|
|
943
|
+
const schema = {
|
|
944
|
+
messageIds: z.array(z.string()).max(MAIL_TEXT_MAX_IDS).optional().describe(
|
|
945
|
+
`Ids of the messages to read (max ${MAIL_TEXT_MAX_IDS}). Give messageIds or folderId, not both.`
|
|
946
|
+
),
|
|
947
|
+
folderId: z.string().optional().describe(
|
|
948
|
+
"Folder to read from: a well-known name (inbox, sentitems, drafts, archive, deleteditems, junkemail) or a folder id."
|
|
949
|
+
),
|
|
950
|
+
top: z.number().int().min(1).max(MAIL_TEXT_MAX_TOP).optional().describe(
|
|
951
|
+
`With folderId: how many messages, newest first (default and max ${MAIL_TEXT_MAX_TOP}).`
|
|
952
|
+
),
|
|
953
|
+
filter: z.string().optional().describe(
|
|
954
|
+
"With folderId: OData $filter, e.g. receivedDateTime ge 2026-10-01T00:00:00Z or from/emailAddress/address eq 'a@example.com'."
|
|
955
|
+
),
|
|
956
|
+
search: z.string().optional().describe(
|
|
957
|
+
'With folderId: KQL search, e.g. from:paul subject:"quarterly report" (quoting is normalised as for list-mail-messages). Graph cannot combine it with filter.'
|
|
958
|
+
),
|
|
959
|
+
maxChars: z.number().int().min(1).max(MAIL_TEXT_MAX_INPUT_CHARS).optional().describe(
|
|
960
|
+
"Cut each message text to this many characters (marked truncated). Default 3,000; pass up to 300,000 for the whole of a long message (a body over 300,000 characters is cleaned only that far, also marked truncated)."
|
|
961
|
+
)
|
|
962
|
+
};
|
|
963
|
+
if (ctx.multiAccount) {
|
|
964
|
+
schema["account"] = z.string().optional().describe(
|
|
965
|
+
"Account to use when multiple Microsoft accounts are configured. Required when multiple accounts exist (see list-accounts)."
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
return schema;
|
|
969
|
+
},
|
|
970
|
+
execute: async (params, { graphClient, authManager }) => {
|
|
971
|
+
const fail = (error) => ({
|
|
972
|
+
content: [{ type: "text", text: JSON.stringify({ error }) }],
|
|
973
|
+
isError: true
|
|
974
|
+
});
|
|
975
|
+
const invalid = validateMailTextArgs(params);
|
|
976
|
+
if (invalid) return fail(invalid);
|
|
977
|
+
const ids = params.messageIds;
|
|
978
|
+
const folderId = params.folderId;
|
|
979
|
+
const top = params.top;
|
|
980
|
+
const filter = params.filter;
|
|
981
|
+
const search = params.search;
|
|
982
|
+
const maxChars = params.maxChars ?? MAIL_TEXT_DEFAULT_MAX_CHARS;
|
|
983
|
+
const accountParam = params.account;
|
|
984
|
+
const envLimit = Number(process.env.MS365_MCP_READ_MAIL_TEXT_MAX_CHARS);
|
|
985
|
+
const limit = Number.isInteger(envLimit) && envLimit > 0 ? envLimit : MAIL_TEXT_DEFAULT_MAX_RESPONSE_CHARS;
|
|
986
|
+
try {
|
|
987
|
+
const accountModeError = await checkAccountParamInBearerMode(accountParam, authManager);
|
|
988
|
+
if (accountModeError) return fail(accountModeError);
|
|
989
|
+
let accessToken;
|
|
990
|
+
if (authManager && !authManager.isOAuthModeEnabled() && !getRequestTokens()) {
|
|
991
|
+
accessToken = await authManager.getTokenForAccount(accountParam);
|
|
992
|
+
}
|
|
993
|
+
const requestJson = async (endpoint, options) => {
|
|
994
|
+
const response = await graphClient.graphRequest(endpoint, {
|
|
995
|
+
...options,
|
|
996
|
+
accessToken,
|
|
997
|
+
forceJsonOutput: true
|
|
998
|
+
});
|
|
999
|
+
if (response?.isError) return { error: response };
|
|
1000
|
+
const text2 = response?.content?.[0]?.text;
|
|
1001
|
+
try {
|
|
1002
|
+
return { data: JSON.parse(typeof text2 === "string" ? text2 : "") };
|
|
1003
|
+
} catch {
|
|
1004
|
+
return { error: fail("Unexpected non-JSON response from Microsoft Graph.") };
|
|
1005
|
+
}
|
|
1006
|
+
};
|
|
1007
|
+
let entries;
|
|
1008
|
+
if (ids !== void 0) {
|
|
1009
|
+
const requests = ids.map((id, i) => ({
|
|
1010
|
+
id: String(i),
|
|
1011
|
+
method: "GET",
|
|
1012
|
+
url: `/me/messages/${encodeURIComponent(id)}?$select=${MAIL_TEXT_SELECT}`,
|
|
1013
|
+
headers: { Prefer: PREFER_TEXT_BODY }
|
|
1014
|
+
}));
|
|
1015
|
+
const r = await requestJson("/$batch", {
|
|
1016
|
+
method: "POST",
|
|
1017
|
+
body: JSON.stringify({ requests }),
|
|
1018
|
+
headers: { "Content-Type": "application/json" }
|
|
1019
|
+
});
|
|
1020
|
+
if (r.error) return r.error;
|
|
1021
|
+
const responses = r.data?.responses ?? [];
|
|
1022
|
+
const byId = new Map(responses.map((x) => [x.id, x]));
|
|
1023
|
+
entries = ids.map((id, i) => {
|
|
1024
|
+
const x = byId.get(String(i));
|
|
1025
|
+
if (!x) return { id, error: "no response" };
|
|
1026
|
+
if (x.status !== 200) {
|
|
1027
|
+
const e = x.body?.error;
|
|
1028
|
+
return {
|
|
1029
|
+
id,
|
|
1030
|
+
error: x.status === 404 ? "not found" : `${e?.code ?? x.status}: ${e?.message ?? ""}`.trim()
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
return toMailTextEntry(x.body ?? {}, maxChars);
|
|
1034
|
+
});
|
|
1035
|
+
} else {
|
|
1036
|
+
const queryParams = {
|
|
1037
|
+
$select: MAIL_TEXT_SELECT,
|
|
1038
|
+
$top: String(top ?? MAIL_TEXT_MAX_TOP)
|
|
1039
|
+
};
|
|
1040
|
+
if (filter !== void 0) queryParams["$filter"] = filter;
|
|
1041
|
+
if (search !== void 0) {
|
|
1042
|
+
queryParams["$search"] = search;
|
|
1043
|
+
const searchError = normalizeSearchQueryParam(
|
|
1044
|
+
queryParams,
|
|
1045
|
+
"/me/mailFolders/{mailFolder-id}/messages",
|
|
1046
|
+
"read-mail-text"
|
|
1047
|
+
);
|
|
1048
|
+
if (searchError) return searchError;
|
|
1049
|
+
}
|
|
1050
|
+
const r = await requestJson(
|
|
1051
|
+
`/me/mailFolders/${encodeURIComponent(folderId)}/messages?${new URLSearchParams(queryParams)}`,
|
|
1052
|
+
{ headers: { Prefer: PREFER_TEXT_BODY } }
|
|
1053
|
+
);
|
|
1054
|
+
if (r.error) return r.error;
|
|
1055
|
+
entries = (r.data?.value ?? []).map(
|
|
1056
|
+
(m) => toMailTextEntry(m, maxChars)
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
const text = JSON.stringify(entries);
|
|
1060
|
+
if (text.length > limit) return fail(oversizeError(entries, text.length, limit));
|
|
1061
|
+
return { content: [{ type: "text", text }] };
|
|
1062
|
+
} catch (error) {
|
|
1063
|
+
return fail(error.message);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
},
|
|
924
1067
|
{
|
|
925
1068
|
name: "download-bytes",
|
|
926
1069
|
method: "GET",
|
|
@@ -1645,8 +1788,7 @@ async function executeGraphTool(tool, config, graphClient, params, authManager,
|
|
|
1645
1788
|
(p) => p.name === paramName || p.name === camelCaseParamName || isOdataParam && p.name.replace(/^\$/, "").toLowerCase() === normalizedParamName
|
|
1646
1789
|
);
|
|
1647
1790
|
const isQuery = paramDef?.type === "Query" || isOdataParam;
|
|
1648
|
-
|
|
1649
|
-
if (isQuery && !ignoredDeltaTop && paramValue != null && paramValue !== "") {
|
|
1791
|
+
if (isQuery && paramValue != null && paramValue !== "") {
|
|
1650
1792
|
const schema = queryParameterSchema(
|
|
1651
1793
|
tool.alias,
|
|
1652
1794
|
normalizedParamName,
|
|
@@ -1797,9 +1939,6 @@ async function executeGraphTool(tool, config, graphClient, params, authManager,
|
|
|
1797
1939
|
}
|
|
1798
1940
|
body = rewritten.body;
|
|
1799
1941
|
}
|
|
1800
|
-
if (TOP_UNSUPPORTED_DELTA_TOOLS.has(tool.alias)) {
|
|
1801
|
-
delete queryParams["$top"];
|
|
1802
|
-
}
|
|
1803
1942
|
const skiptokenError = normalizeSkiptokenQueryParam(queryParams, tool.alias);
|
|
1804
1943
|
if (skiptokenError) return skiptokenError;
|
|
1805
1944
|
clampTopQueryParam(queryParams);
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
const MAIL_TEXT_MAX_IDS = 20;
|
|
2
|
+
const MAIL_TEXT_MAX_TOP = 20;
|
|
3
|
+
const MAIL_TEXT_DEFAULT_MAX_RESPONSE_CHARS = 8e4;
|
|
4
|
+
const MAIL_TEXT_DEFAULT_MAX_CHARS = 3e3;
|
|
5
|
+
const MAIL_TEXT_MAX_INPUT_CHARS = 3e5;
|
|
6
|
+
const MAIL_TEXT_SELECT = "id,subject,from,toRecipients,ccRecipients,receivedDateTime,hasAttachments,body";
|
|
7
|
+
const PREFER_TEXT_BODY = 'outlook.body-content-type="text"';
|
|
8
|
+
const QUOTE_START = [
|
|
9
|
+
/^On .{1,300}wrote:\s*$/i,
|
|
10
|
+
// Gmail / Apple Mail
|
|
11
|
+
/^-{2,}\s*Original Message\s*-{2,}/i,
|
|
12
|
+
// Outlook classic
|
|
13
|
+
/^Le .{1,300}a écrit\s*:\s*$/i,
|
|
14
|
+
/^Am .{1,300}schrieb .{0,200}:\s*$/i
|
|
15
|
+
];
|
|
16
|
+
const DATED = [true, false, true, true];
|
|
17
|
+
function isReplyHeader(l) {
|
|
18
|
+
return QUOTE_START.some((r, k) => r.test(l) && (!DATED[k] || /\d/.test(l)));
|
|
19
|
+
}
|
|
20
|
+
const HEADER_FROM = /^\*?From:\*?\s+\S/i;
|
|
21
|
+
const HEADER_NEXT = /^\*?(Sent|Date|To|Subject):\*?\s/i;
|
|
22
|
+
const SEPARATOR = /^_{10,}\s*$/;
|
|
23
|
+
function isHeaderBlock(lines, i) {
|
|
24
|
+
const l = lines[i]?.trim() ?? "";
|
|
25
|
+
if (l.length > 2e3 || !HEADER_FROM.test(l)) return false;
|
|
26
|
+
let headers = 0;
|
|
27
|
+
for (let j = i + 1; j < Math.min(lines.length, i + 6); j++) {
|
|
28
|
+
if (lines[j].length < 2e3 && HEADER_NEXT.test(lines[j].trim())) headers++;
|
|
29
|
+
}
|
|
30
|
+
return headers >= 2;
|
|
31
|
+
}
|
|
32
|
+
const FORWARD_MARKER = /^(?:-{2,}\s*Forwarded message\s*-{2,}|Begin forwarded message:)$/i;
|
|
33
|
+
function forwardMarkerAbove(lines, q) {
|
|
34
|
+
let seen = 0;
|
|
35
|
+
for (let j = q - 1; j >= 0 && seen < 3; j--) {
|
|
36
|
+
const l = lines[j].trim();
|
|
37
|
+
if (!l) continue;
|
|
38
|
+
if (l.length < 100 && FORWARD_MARKER.test(l)) return true;
|
|
39
|
+
seen++;
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
function quoteStart(lines) {
|
|
44
|
+
for (let i = 0; i < lines.length; i++) {
|
|
45
|
+
const l = lines[i].trim();
|
|
46
|
+
if (l.length > 2e3) continue;
|
|
47
|
+
if (isReplyHeader(l)) return i;
|
|
48
|
+
if (l.startsWith(">") && isReplyHeader(l.replace(/^(?:>[ \t]?){1,20}/, "").trim())) return i;
|
|
49
|
+
if (/^On .{1,200}/i.test(l) && /\d/.test(l) && i + 1 < lines.length && l.length + lines[i + 1].length < 320 && /wrote:\s*$/i.test(lines[i + 1].trim())) {
|
|
50
|
+
return i;
|
|
51
|
+
}
|
|
52
|
+
if (isHeaderBlock(lines, i)) return i;
|
|
53
|
+
if (SEPARATOR.test(l)) {
|
|
54
|
+
let j = i + 1;
|
|
55
|
+
while (j < lines.length && j <= i + 2 && !lines[j].trim()) j++;
|
|
56
|
+
if (isHeaderBlock(lines, j)) return i;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return -1;
|
|
60
|
+
}
|
|
61
|
+
const MARK_OPEN = "\u27E6";
|
|
62
|
+
const mark = (host) => `\u27E6link: ${host}\u27E7`;
|
|
63
|
+
const MAX_KEPT_URL = 300;
|
|
64
|
+
const TRACKING_PARAM = /^(utm_[a-z_]+|mc_cid|mc_eid|_hsenc|_hsmi|__hstc|__hssc|__hsfp|mkt_tok|gclid|dclid|fbclid|msclkid|yclid|igshid)$/i;
|
|
65
|
+
const LINKEDIN_TRACKING_PARAM = /^(trackingid|refid|lipi|midtoken|midsig|trk|trkemail)$/i;
|
|
66
|
+
const RUN_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
67
|
+
function unwrapUrlDefenseV3(u) {
|
|
68
|
+
const m = /^https:\/\/urldefense\.com\/v3\/__(.{1,4096}?)__;([A-Za-z0-9_-]{0,4096})!/.exec(u);
|
|
69
|
+
if (!m) return void 0;
|
|
70
|
+
const replaced = [...Buffer.from(m[2], "base64url").toString("utf8")];
|
|
71
|
+
let next = 0;
|
|
72
|
+
let out = m[1].replace(/\*\*(.)|\*/g, (tok, run) => {
|
|
73
|
+
const n = run === void 0 ? 1 : RUN_CHARS.indexOf(run) + 2;
|
|
74
|
+
if (n < 1) return tok;
|
|
75
|
+
const s = replaced.slice(next, next + n).join("");
|
|
76
|
+
next += n;
|
|
77
|
+
return s;
|
|
78
|
+
});
|
|
79
|
+
out = out.replace(/^(https?:)\/(?!\/)/i, "$1//");
|
|
80
|
+
return new URL(out).href;
|
|
81
|
+
}
|
|
82
|
+
function unwrapOnce(u) {
|
|
83
|
+
const url = new URL(u);
|
|
84
|
+
const host = url.hostname.toLowerCase();
|
|
85
|
+
if (/(^|\.)safelinks\.protection\.outlook\.com$/.test(host)) {
|
|
86
|
+
const w = url.searchParams.get("url");
|
|
87
|
+
return w ? new URL(w).href : void 0;
|
|
88
|
+
}
|
|
89
|
+
if (host === "urldefense.com") return unwrapUrlDefenseV3(u);
|
|
90
|
+
if (host === "urldefense.proofpoint.com") {
|
|
91
|
+
const w = url.searchParams.get("u");
|
|
92
|
+
return w ? new URL(decodeURIComponent(w.replace(/_/g, "/").replace(/-/g, "%"))).href : void 0;
|
|
93
|
+
}
|
|
94
|
+
if (/^(www\.)?google\.[a-z.]{2,6}$/.test(host) && url.pathname === "/url") {
|
|
95
|
+
const w = url.searchParams.get("q") ?? url.searchParams.get("url");
|
|
96
|
+
return w && /^https?:\/\//i.test(w) ? new URL(w).href : void 0;
|
|
97
|
+
}
|
|
98
|
+
return void 0;
|
|
99
|
+
}
|
|
100
|
+
function unwrapLink(u) {
|
|
101
|
+
let real = u;
|
|
102
|
+
for (let i = 0; i < 3; i++) {
|
|
103
|
+
let next;
|
|
104
|
+
try {
|
|
105
|
+
next = unwrapOnce(real);
|
|
106
|
+
} catch {
|
|
107
|
+
next = void 0;
|
|
108
|
+
}
|
|
109
|
+
if (!next || next === real) break;
|
|
110
|
+
real = next;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const r = new URL(real);
|
|
114
|
+
const linkedIn = /(^|\.)linkedin\.com$/i.test(r.hostname);
|
|
115
|
+
let dropped = false;
|
|
116
|
+
for (const k of [...r.searchParams.keys()]) {
|
|
117
|
+
if (TRACKING_PARAM.test(k) || linkedIn && LINKEDIN_TRACKING_PARAM.test(k)) {
|
|
118
|
+
r.searchParams.delete(k);
|
|
119
|
+
dropped = true;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return dropped ? r.href : real;
|
|
123
|
+
} catch {
|
|
124
|
+
return real;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function hostOf(u) {
|
|
128
|
+
try {
|
|
129
|
+
return new URL(u).hostname.replace(/^www\./, "") || "link";
|
|
130
|
+
} catch {
|
|
131
|
+
return "link";
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function showLink(u) {
|
|
135
|
+
const real = unwrapLink(u);
|
|
136
|
+
return real.length <= MAX_KEPT_URL ? real : mark(hostOf(real));
|
|
137
|
+
}
|
|
138
|
+
function splitUrlTail(u) {
|
|
139
|
+
let opens = 0;
|
|
140
|
+
let closes = 0;
|
|
141
|
+
for (const c of u) {
|
|
142
|
+
if (c === "(") opens++;
|
|
143
|
+
else if (c === ")") closes++;
|
|
144
|
+
}
|
|
145
|
+
let end = u.length;
|
|
146
|
+
while (end > 0) {
|
|
147
|
+
const c = u[end - 1];
|
|
148
|
+
if (".,;:!?".includes(c)) {
|
|
149
|
+
end--;
|
|
150
|
+
} else if (c === ")" && closes > opens) {
|
|
151
|
+
end--;
|
|
152
|
+
closes--;
|
|
153
|
+
} else {
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return [u.slice(0, end), u.slice(end)];
|
|
158
|
+
}
|
|
159
|
+
function isImageUrl(u) {
|
|
160
|
+
try {
|
|
161
|
+
return /\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(new URL(u).pathname);
|
|
162
|
+
} catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function cleanLinks(t) {
|
|
167
|
+
t = t.replace(/<((?:https?|mailto|tel):[^<>\s]{1,4096})>/gi, (m, u) => {
|
|
168
|
+
if (/^(mailto|tel):/i.test(u)) return m;
|
|
169
|
+
const shown = showLink(u);
|
|
170
|
+
return shown.startsWith(MARK_OPEN) ? shown : `<${shown}>`;
|
|
171
|
+
});
|
|
172
|
+
return t.replace(
|
|
173
|
+
/(^|[^<])\b(https?:\/\/[^\s<>"'[\]\u27e6\u27e7]{1,4096})/gi,
|
|
174
|
+
(_m, pre, raw) => {
|
|
175
|
+
const [u, tail] = splitUrlTail(raw);
|
|
176
|
+
return pre + showLink(u) + tail;
|
|
177
|
+
}
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
const IMAGE_PLACEHOLDER = /\[[ \t]{0,20}(https?:\/\/[^\s<>"'[\]\u27e6\u27e7]{1,4096})[ \t]{0,20}\]/gi;
|
|
181
|
+
function cleanMailText(raw, opts = {}) {
|
|
182
|
+
const t = String(raw ?? "").replace(/\r\n?/g, "\n").replace(/(?:(?:[\u200b-\u200d\u2060\ufeff\u00ad]|\u034f)[ \t\u00a0\u2007\u202f]*){3,}/g, " ").replace(/\u27e6/g, "[").replace(/\u27e7/g, "]");
|
|
183
|
+
let lines = t.split("\n");
|
|
184
|
+
if (!opts.keepQuoted) {
|
|
185
|
+
const q = quoteStart(lines);
|
|
186
|
+
if (q >= 0 && lines.slice(0, q).join("").trim() && !forwardMarkerAbove(lines, q)) {
|
|
187
|
+
lines = lines.slice(0, q).concat("");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const body = lines.join("\n").replace(IMAGE_PLACEHOLDER, (m, u) => isImageUrl(u) ? "" : m);
|
|
191
|
+
return cleanLinks(body);
|
|
192
|
+
}
|
|
193
|
+
function validateMailTextArgs(p) {
|
|
194
|
+
const isInt = (v, min, max = Number.MAX_SAFE_INTEGER) => typeof v === "number" && Number.isInteger(v) && v >= min && v <= max;
|
|
195
|
+
const hasIds = p.messageIds !== void 0;
|
|
196
|
+
const hasFolder = p.folderId !== void 0;
|
|
197
|
+
if (hasIds === hasFolder) return "Give messageIds or folderId (exactly one).";
|
|
198
|
+
if (p.maxChars !== void 0 && !isInt(p.maxChars, 1, MAIL_TEXT_MAX_INPUT_CHARS)) {
|
|
199
|
+
return `maxChars must be an integer from 1 to ${MAIL_TEXT_MAX_INPUT_CHARS}.`;
|
|
200
|
+
}
|
|
201
|
+
if (hasIds) {
|
|
202
|
+
const ids = p.messageIds;
|
|
203
|
+
if (!Array.isArray(ids) || ids.length === 0 || ids.some((x) => typeof x !== "string" || !x.trim())) {
|
|
204
|
+
return "messageIds must be a non-empty list of message ids.";
|
|
205
|
+
}
|
|
206
|
+
if (ids.length > MAIL_TEXT_MAX_IDS) return `At most ${MAIL_TEXT_MAX_IDS} messageIds per call.`;
|
|
207
|
+
if (p.top !== void 0 || p.filter !== void 0 || p.search !== void 0) {
|
|
208
|
+
return "top, filter and search apply only with folderId.";
|
|
209
|
+
}
|
|
210
|
+
return void 0;
|
|
211
|
+
}
|
|
212
|
+
if (typeof p.folderId !== "string" || !p.folderId.trim()) {
|
|
213
|
+
return "folderId must be a non-empty string.";
|
|
214
|
+
}
|
|
215
|
+
if (p.top !== void 0 && !isInt(p.top, 1, MAIL_TEXT_MAX_TOP)) {
|
|
216
|
+
return `top must be an integer from 1 to ${MAIL_TEXT_MAX_TOP}.`;
|
|
217
|
+
}
|
|
218
|
+
for (const k of ["filter", "search"]) {
|
|
219
|
+
const v = p[k];
|
|
220
|
+
if (v !== void 0 && (typeof v !== "string" || !v.trim())) {
|
|
221
|
+
return `${k} must be a non-empty string (omit it to read the newest messages).`;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (p.filter !== void 0 && p.search !== void 0) {
|
|
225
|
+
return "Microsoft Graph cannot combine filter and search on messages; use one.";
|
|
226
|
+
}
|
|
227
|
+
return void 0;
|
|
228
|
+
}
|
|
229
|
+
function formatAddress(r) {
|
|
230
|
+
const e = r?.emailAddress;
|
|
231
|
+
if (!e) return "";
|
|
232
|
+
return e.name && e.address && e.name !== e.address ? `${e.name} <${e.address}>` : e.address || e.name || "";
|
|
233
|
+
}
|
|
234
|
+
const FORWARD_SUBJECT = new RegExp(
|
|
235
|
+
"^\\s*(?:\\[[^\\]\\n]{0,60}\\]\\s*){0,5}(?:fwd?|vs|vb|wg|tr|trs|rv|i|enc|doorst|vl|ed|fs|pd|yml|ilt|\u0130lt|tov\xE1bb\xEDt\xE1s|p\u0101rs\\.|\u03C0\u03C1\u03B8|\u8F6C\u53D1|\u8F49\u5BC4|\u05D4\u05D5\u05E2\u05D1\u05E8|\u05D4\u05E2\u05D1\u05E8\u05D4|\u0625\u0639\u0627\u062F\u0629 \u062A\u0648\u062C\u064A\u0647|\u0BAE\u0BC1\u0BA9\u0BCD\u0BA9\u0BA9\u0BC1\u0BAA\u0BCD\u0BAA\u0BC1)\\s*[:\uFF1A]",
|
|
236
|
+
"iu"
|
|
237
|
+
);
|
|
238
|
+
function isForwardSubject(subject) {
|
|
239
|
+
return FORWARD_SUBJECT.test(subject.slice(0, 500).normalize("NFC"));
|
|
240
|
+
}
|
|
241
|
+
function cutText(s, n) {
|
|
242
|
+
if (s.length <= n) return s;
|
|
243
|
+
const c = s.charCodeAt(n - 1);
|
|
244
|
+
return s.slice(0, c >= 55296 && c <= 56319 ? n - 1 : n);
|
|
245
|
+
}
|
|
246
|
+
function toMailTextEntry(m, maxChars) {
|
|
247
|
+
const subject = m.subject ?? "";
|
|
248
|
+
const keepQuoted = isForwardSubject(subject);
|
|
249
|
+
let raw = m.body?.content ?? "";
|
|
250
|
+
let truncated = false;
|
|
251
|
+
if (raw.length > MAIL_TEXT_MAX_INPUT_CHARS) {
|
|
252
|
+
raw = cutText(raw, MAIL_TEXT_MAX_INPUT_CHARS);
|
|
253
|
+
truncated = true;
|
|
254
|
+
}
|
|
255
|
+
let text = cleanMailText(raw, { keepQuoted });
|
|
256
|
+
const chars = text.length;
|
|
257
|
+
if (maxChars && chars > maxChars) {
|
|
258
|
+
text = cutText(text, maxChars);
|
|
259
|
+
truncated = true;
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
id: m.id ?? "",
|
|
263
|
+
from: formatAddress(m.from),
|
|
264
|
+
to: (m.toRecipients ?? []).map(formatAddress),
|
|
265
|
+
cc: (m.ccRecipients ?? []).map(formatAddress),
|
|
266
|
+
subject,
|
|
267
|
+
received: m.receivedDateTime,
|
|
268
|
+
hasAttachments: !!m.hasAttachments,
|
|
269
|
+
text,
|
|
270
|
+
chars,
|
|
271
|
+
truncated
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
function oversizeError(entries, size, limit) {
|
|
275
|
+
const fmt = (n) => n.toLocaleString("en-US");
|
|
276
|
+
const largest = entries.filter((e) => "text" in e).sort((a, b) => b.text.length - a.text.length).slice(0, 5).map((e) => `"${(e.subject || "(no subject)").slice(0, 60)}" ${fmt(e.text.length)}`).join(", ");
|
|
277
|
+
return `Response would be ${fmt(size)} characters (limit ${fmt(limit)}). Largest: ${largest}. Narrow the query (fewer messages via top, a filter such as a date range or sender, specific messageIds) or set a smaller maxChars.`;
|
|
278
|
+
}
|
|
279
|
+
export {
|
|
280
|
+
MAIL_TEXT_DEFAULT_MAX_CHARS,
|
|
281
|
+
MAIL_TEXT_DEFAULT_MAX_RESPONSE_CHARS,
|
|
282
|
+
MAIL_TEXT_MAX_IDS,
|
|
283
|
+
MAIL_TEXT_MAX_INPUT_CHARS,
|
|
284
|
+
MAIL_TEXT_MAX_TOP,
|
|
285
|
+
MAIL_TEXT_SELECT,
|
|
286
|
+
PREFER_TEXT_BODY,
|
|
287
|
+
cleanMailText,
|
|
288
|
+
cutText,
|
|
289
|
+
isForwardSubject,
|
|
290
|
+
oversizeError,
|
|
291
|
+
toMailTextEntry,
|
|
292
|
+
validateMailTextArgs
|
|
293
|
+
};
|
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
import logger from "../logger.js";
|
|
2
|
-
const
|
|
3
|
-
|
|
4
|
-
"list-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
2
|
+
const SUPPORTED_QUERY_PARAMS = {
|
|
3
|
+
// https://learn.microsoft.com/en-us/graph/api/user-list-joinedteams
|
|
4
|
+
"list-joined-teams": [],
|
|
5
|
+
// https://learn.microsoft.com/en-us/graph/api/associatedteaminfo-list
|
|
6
|
+
"list-my-associated-teams": [],
|
|
7
|
+
// Documents only $top/$filter; $select is needed to opt in to image bytes.
|
|
8
|
+
// https://learn.microsoft.com/graph/api/teamworkmessaging-list-customemojis?view=graph-rest-beta
|
|
9
|
+
"list-custom-emojis": ["top", "filter", "select", "skiptoken"],
|
|
10
|
+
// https://learn.microsoft.com/en-us/graph/api/channel-list-messages
|
|
11
|
+
"list-channel-messages": ["top", "expand", "skiptoken"],
|
|
12
|
+
// https://learn.microsoft.com/en-us/graph/api/chatmessage-list-replies
|
|
13
|
+
"list-channel-message-replies": ["top", "skiptoken"],
|
|
14
|
+
// Delta rejects $select/$expand/$filter/$orderby/$search and documents no $top/$skip/$count;
|
|
15
|
+
// events delta takes no endDateTime. https://learn.microsoft.com/graph/api/event-delta?view=graph-rest-beta
|
|
16
|
+
"list-calendar-events-delta": ["startdatetime", "skiptoken"],
|
|
17
|
+
"list-calendar-view-delta": ["startdatetime", "enddatetime", "skiptoken"]
|
|
18
|
+
};
|
|
19
|
+
function isQueryParamSupported(toolAlias, bareName) {
|
|
20
|
+
return SUPPORTED_QUERY_PARAMS[toolAlias]?.includes(bareName) ?? true;
|
|
8
21
|
}
|
|
9
22
|
function paginationAllowed() {
|
|
10
23
|
const raw = process.env.MS365_MCP_ALLOW_PAGINATION;
|
|
@@ -39,6 +52,8 @@ const SKIPTOKEN_PARAM_DESCRIPTION = "Next page: the @odata.nextLink from the pre
|
|
|
39
52
|
const COLLECTION_QUERY_PARAMS = /* @__PURE__ */ new Set(["top", "filter", "orderby", "count"]);
|
|
40
53
|
function isSkiptokenApplicable(tool, paramNames) {
|
|
41
54
|
if (tool.method.toUpperCase() !== "GET") return false;
|
|
55
|
+
const supported = tool.alias ? SUPPORTED_QUERY_PARAMS[tool.alias] : void 0;
|
|
56
|
+
if (supported) return supported.includes("skiptoken");
|
|
42
57
|
for (const name of paramNames) {
|
|
43
58
|
if (COLLECTION_QUERY_PARAMS.has(name.replace(/^\$/, "").toLowerCase())) return true;
|
|
44
59
|
}
|
|
@@ -95,15 +110,14 @@ export {
|
|
|
95
110
|
SKIP_PARAM_DESCRIPTION,
|
|
96
111
|
TIMEZONE_PARAM_DESCRIPTION,
|
|
97
112
|
TOP_PARAM_DESCRIPTION,
|
|
98
|
-
TOP_UNSUPPORTED_DELTA_TOOLS,
|
|
99
113
|
getAcceptParamDescription,
|
|
100
114
|
getAccountParamDescription,
|
|
101
115
|
getFetchAllPagesParamDescription,
|
|
102
116
|
getMaxPages,
|
|
103
117
|
getODataParamDescription,
|
|
104
118
|
isFetchAllPagesApplicable,
|
|
119
|
+
isQueryParamSupported,
|
|
105
120
|
isSkiptokenApplicable,
|
|
106
121
|
paginationAllowed,
|
|
107
|
-
positiveIntFromEnv
|
|
108
|
-
shouldOmitTopParam
|
|
122
|
+
positiveIntFromEnv
|
|
109
123
|
};
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { getODataParamDescription,
|
|
3
|
-
const TEAM_LIST_TOOLS = /* @__PURE__ */ new Set(["list-joined-teams", "list-my-associated-teams"]);
|
|
2
|
+
import { getODataParamDescription, isQueryParamSupported } from "./param-descriptions.js";
|
|
4
3
|
const CUSTOM_EMOJI_QUERY_DESCRIPTIONS = {
|
|
5
4
|
top: "Number of custom emojis to return in one page. Base64 image content is opt-in with select; use a small page size when selecting contentBytes.",
|
|
6
5
|
filter: "OData filter expression for custom emojis, forwarded to Microsoft Graph. Filter support is determined by the beta API.",
|
|
@@ -8,11 +7,7 @@ const CUSTOM_EMOJI_QUERY_DESCRIPTIONS = {
|
|
|
8
7
|
};
|
|
9
8
|
function queryParameterSchema(toolName, name, providerSchema) {
|
|
10
9
|
const bareName = name.replace(/^\$/, "").toLowerCase();
|
|
11
|
-
if (
|
|
12
|
-
if (toolName === "list-custom-emojis" && !["top", "filter", "select", "skiptoken"].includes(bareName)) {
|
|
13
|
-
return void 0;
|
|
14
|
-
}
|
|
15
|
-
if (bareName === "top" && shouldOmitTopParam(toolName)) return void 0;
|
|
10
|
+
if (!isQueryParamSupported(toolName, bareName)) return void 0;
|
|
16
11
|
const source = providerSchema instanceof z.ZodOptional ? providerSchema.unwrap() : providerSchema;
|
|
17
12
|
let schema = source;
|
|
18
13
|
switch (bareName) {
|
package/dist/lib/tool-schema.js
CHANGED
package/dist/tool-categories.js
CHANGED
|
@@ -64,7 +64,8 @@ const SCOPED_UTILITY_TOOLS = {
|
|
|
64
64
|
"get-download-url": ["files", "onedrive", "personal", "work", "search"],
|
|
65
65
|
"get-upload-url": ["mail", "calendar", "outlook", "personal", "work"],
|
|
66
66
|
"upload-attachment-from-file": ["mail", "calendar", "outlook", "personal", "work"],
|
|
67
|
-
"parse-teams-url": ["teams", "teams-write", "work"]
|
|
67
|
+
"parse-teams-url": ["teams", "teams-write", "work"],
|
|
68
|
+
"read-mail-text": ["mail", "outlook", "personal", "work"]
|
|
68
69
|
};
|
|
69
70
|
for (const [tool, presets] of Object.entries(SCOPED_UTILITY_TOOLS)) {
|
|
70
71
|
for (const preset of presets) {
|
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.
|
|
4
|
+
"version": "0.169.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",
|
package/src/endpoints.json
CHANGED
|
@@ -2543,7 +2543,7 @@
|
|
|
2543
2543
|
"toolName": "list-calendar-events-delta",
|
|
2544
2544
|
"presets": ["calendar", "outlook", "personal"],
|
|
2545
2545
|
"scopes": ["Calendars.Read"],
|
|
2546
|
-
"llmTip": "Incremental sync of events across the default calendar. First call returns all events plus @odata.deltaLink. Subsequent calls with that link return only additions/updates/removals.
|
|
2546
|
+
"llmTip": "Incremental sync of events across the default calendar. First call returns all events plus @odata.deltaLink. Subsequent calls with that link return only additions/updates/removals. Deltas expire after ~30 days — start over if the server returns 410 Gone. For a time-bounded view with delta semantics, use list-calendar-view-delta instead."
|
|
2547
2547
|
},
|
|
2548
2548
|
{
|
|
2549
2549
|
"pathPattern": "/me/calendarView/delta()",
|