@softeria/ms-365-mcp-server 0.154.0 → 0.154.2
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/Dockerfile.ghcr +29 -0
- package/dist/__tests__/graph-client.test.js +115 -0
- package/dist/__tests__/graph-tools.test.js +91 -0
- package/dist/generated/client.js +16 -16
- package/dist/graph-client.js +20 -15
- package/dist/graph-tools.js +37 -48
- package/dist/lib/query-parameter-schema.js +33 -0
- package/dist/lib/tool-schema.js +14 -16
- package/docs/deployment.md +19 -1
- package/package.json +1 -1
package/Dockerfile.ghcr
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# The image published to ghcr.io/softeria/ms-365-mcp-server. It installs the released npm
|
|
2
|
+
# package rather than building from source, so a version tag holds exactly what npm serves
|
|
3
|
+
# for that version. `generate` downloads Microsoft's live spec, so a source build of the
|
|
4
|
+
# same version can drift from it.
|
|
5
|
+
#
|
|
6
|
+
# Separate from Dockerfile because the legacy (non-BuildKit) builder runs every stage in a
|
|
7
|
+
# file whatever --target says, which would break the source build documented in
|
|
8
|
+
# docs/deployment.md.
|
|
9
|
+
FROM node:24-alpine
|
|
10
|
+
|
|
11
|
+
LABEL org.opencontainers.image.source="https://github.com/Softeria/ms-365-mcp-server"
|
|
12
|
+
|
|
13
|
+
# No default. npm resolves an empty spec to latest, which would stamp whatever is newest
|
|
14
|
+
# with the tag the workflow passes.
|
|
15
|
+
ARG VERSION
|
|
16
|
+
|
|
17
|
+
ENV NODE_ENV=production
|
|
18
|
+
|
|
19
|
+
RUN test -n "$VERSION" || { echo 'VERSION build arg is required'; exit 1; }
|
|
20
|
+
|
|
21
|
+
# --ignore-scripts keeps keytar (optional, native, no musl prebuild) from attempting a
|
|
22
|
+
# node-gyp build that cannot succeed here. --omit=optional would drop @azure/identity and
|
|
23
|
+
# @azure/keyvault-secrets with it, which Key Vault mode needs.
|
|
24
|
+
RUN npm i -g --ignore-scripts "@softeria/ms-365-mcp-server@${VERSION}" && \
|
|
25
|
+
npm cache clean --force
|
|
26
|
+
|
|
27
|
+
USER node
|
|
28
|
+
|
|
29
|
+
ENTRYPOINT ["ms-365-mcp-server"]
|
|
@@ -88,6 +88,121 @@ describe("GraphClient audit metadata", () => {
|
|
|
88
88
|
const result = await createGraphClient().graphRequest("/me/messages/m1/$value");
|
|
89
89
|
expect(result._meta).toMatchObject({ response_bytes: raw.byteLength });
|
|
90
90
|
});
|
|
91
|
+
describe("forceBinary", () => {
|
|
92
|
+
const doc = Buffer.from([
|
|
93
|
+
208,
|
|
94
|
+
207,
|
|
95
|
+
17,
|
|
96
|
+
224,
|
|
97
|
+
161,
|
|
98
|
+
177,
|
|
99
|
+
26,
|
|
100
|
+
225,
|
|
101
|
+
255,
|
|
102
|
+
254,
|
|
103
|
+
0,
|
|
104
|
+
65
|
|
105
|
+
]);
|
|
106
|
+
it("returns base64 bytes for a content type outside the binary allowlist", async () => {
|
|
107
|
+
fetchWithResilienceMock.mockResolvedValue(
|
|
108
|
+
new Response(doc, { status: 200, headers: { "content-type": "application/msword" } })
|
|
109
|
+
);
|
|
110
|
+
const result = await createGraphClient().graphRequest(
|
|
111
|
+
"/me/messages/m1/attachments/a1/$value",
|
|
112
|
+
{
|
|
113
|
+
rawResponse: true,
|
|
114
|
+
forceBinary: true
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
const payload = JSON.parse(result.content[0].text);
|
|
118
|
+
expect(payload).toMatchObject({
|
|
119
|
+
contentType: "application/msword",
|
|
120
|
+
encoding: "base64",
|
|
121
|
+
contentLength: doc.byteLength
|
|
122
|
+
});
|
|
123
|
+
expect(Buffer.from(payload.contentBytes, "base64").equals(doc)).toBe(true);
|
|
124
|
+
expect(payload).not.toHaveProperty("rawResponse");
|
|
125
|
+
expect(result._meta).toMatchObject({ response_bytes: doc.byteLength });
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
describe("never returns lossy text", () => {
|
|
129
|
+
const cases = [
|
|
130
|
+
{
|
|
131
|
+
name: "Word 97-2003 document",
|
|
132
|
+
contentType: "application/msword",
|
|
133
|
+
body: Buffer.from([208, 207, 17, 224, 161, 177, 26, 225, 255, 254, 0, 65])
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: "RTF with Windows-1252 quotes",
|
|
137
|
+
contentType: "application/rtf",
|
|
138
|
+
body: Buffer.from("{\\rtf1\\ansi \x93quoted\x94}", "latin1")
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: "8-bit MIME message",
|
|
142
|
+
contentType: "message/rfc822",
|
|
143
|
+
body: Buffer.from("Subject: caf\xE9\r\n\r\nna\xEFve body\r\n", "latin1")
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: "text/plain in Windows-1252",
|
|
147
|
+
contentType: "text/plain; charset=windows-1252",
|
|
148
|
+
body: Buffer.from("caf\xE9 \x96 dash", "latin1")
|
|
149
|
+
}
|
|
150
|
+
];
|
|
151
|
+
for (const c of cases) {
|
|
152
|
+
it(`returns ${c.name} (${c.contentType}) as byte-identical base64`, async () => {
|
|
153
|
+
fetchWithResilienceMock.mockResolvedValue(
|
|
154
|
+
new Response(new Uint8Array(c.body), {
|
|
155
|
+
status: 200,
|
|
156
|
+
headers: { "content-type": c.contentType }
|
|
157
|
+
})
|
|
158
|
+
);
|
|
159
|
+
const result = await createGraphClient().graphRequest(
|
|
160
|
+
"/me/messages/m1/attachments/a1/$value",
|
|
161
|
+
{
|
|
162
|
+
rawResponse: true
|
|
163
|
+
}
|
|
164
|
+
);
|
|
165
|
+
const payload = JSON.parse(result.content[0].text);
|
|
166
|
+
expect(payload).not.toHaveProperty("rawResponse");
|
|
167
|
+
expect(payload).toMatchObject({ encoding: "base64", contentLength: c.body.byteLength });
|
|
168
|
+
expect(Buffer.from(payload.contentBytes, "base64").equals(c.body)).toBe(true);
|
|
169
|
+
expect(result._meta).toMatchObject({ response_bytes: c.body.byteLength });
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
it("still returns valid UTF-8 text as text, byte for byte, BOM included", async () => {
|
|
173
|
+
const body = Buffer.concat([
|
|
174
|
+
Buffer.from([239, 187, 191]),
|
|
175
|
+
Buffer.from("h\xE9llo\r\nw\xF6rld\n", "utf8")
|
|
176
|
+
]);
|
|
177
|
+
fetchWithResilienceMock.mockResolvedValue(
|
|
178
|
+
new Response(body, {
|
|
179
|
+
status: 200,
|
|
180
|
+
headers: { "content-type": "text/plain; charset=utf-8" }
|
|
181
|
+
})
|
|
182
|
+
);
|
|
183
|
+
const result = await createGraphClient().graphRequest(
|
|
184
|
+
"/me/messages/m1/attachments/a1/$value",
|
|
185
|
+
{
|
|
186
|
+
rawResponse: true
|
|
187
|
+
}
|
|
188
|
+
);
|
|
189
|
+
const payload = JSON.parse(result.content[0].text);
|
|
190
|
+
expect(payload).not.toHaveProperty("contentBytes");
|
|
191
|
+
expect(Buffer.from(payload.rawResponse, "utf8").equals(body)).toBe(true);
|
|
192
|
+
expect(result._meta).toMatchObject({ response_bytes: body.byteLength });
|
|
193
|
+
});
|
|
194
|
+
it("still parses JSON, even with a leading byte-order mark", async () => {
|
|
195
|
+
const body = Buffer.concat([
|
|
196
|
+
Buffer.from([239, 187, 191]),
|
|
197
|
+
Buffer.from('{"id":"u1"}', "utf8")
|
|
198
|
+
]);
|
|
199
|
+
fetchWithResilienceMock.mockResolvedValue(
|
|
200
|
+
new Response(body, { status: 200, headers: { "content-type": "application/json" } })
|
|
201
|
+
);
|
|
202
|
+
const result = await createGraphClient().graphRequest("/me");
|
|
203
|
+
expect(JSON.parse(result.content[0].text)).toEqual({ id: "u1" });
|
|
204
|
+
});
|
|
205
|
+
});
|
|
91
206
|
it("preserves HTTP status metadata when response headers are requested", async () => {
|
|
92
207
|
fetchWithResilienceMock.mockResolvedValue(
|
|
93
208
|
new Response(JSON.stringify({ id: "task-1" }), {
|
|
@@ -1068,6 +1068,95 @@ describe("graph-tools", () => {
|
|
|
1068
1068
|
expect(payload).not.toHaveProperty("target_resource");
|
|
1069
1069
|
});
|
|
1070
1070
|
});
|
|
1071
|
+
describe("shared query contract at execution", () => {
|
|
1072
|
+
it.each([{ skiptoken: 123 }, { $SKIPTOKEN: 123 }, { COUNT: "true" }, { $Count: "true" }])(
|
|
1073
|
+
"rejects invalid cursor and mixed-case query values: %j",
|
|
1074
|
+
async (params) => {
|
|
1075
|
+
mockEndpoints.push(makeEndpoint());
|
|
1076
|
+
mockEndpointsJson = [makeConfig()];
|
|
1077
|
+
const graphClient = createMockGraphClient();
|
|
1078
|
+
const server = createMockServer();
|
|
1079
|
+
const { registerGraphTools } = await loadModule();
|
|
1080
|
+
registerGraphTools(server, graphClient);
|
|
1081
|
+
const result = await server.tools.get("test-tool").handler(params);
|
|
1082
|
+
expect(result.isError).toBe(true);
|
|
1083
|
+
expect(graphClient.graphRequest).not.toHaveBeenCalled();
|
|
1084
|
+
}
|
|
1085
|
+
);
|
|
1086
|
+
it("accepts valid mixed-case query values and string cursors", async () => {
|
|
1087
|
+
mockEndpoints.push(makeEndpoint());
|
|
1088
|
+
mockEndpointsJson = [makeConfig()];
|
|
1089
|
+
const graphClient = createMockGraphClient();
|
|
1090
|
+
const server = createMockServer();
|
|
1091
|
+
const { registerGraphTools } = await loadModule();
|
|
1092
|
+
registerGraphTools(server, graphClient);
|
|
1093
|
+
await server.tools.get("test-tool").handler({ COUNT: true, $SKIPTOKEN: "next-token" });
|
|
1094
|
+
expect(graphClient.graphRequest).toHaveBeenCalledTimes(1);
|
|
1095
|
+
const request = JSON.stringify(graphClient.graphRequest.mock.calls[0]);
|
|
1096
|
+
expect(request).toContain("$count=true");
|
|
1097
|
+
expect(request).toContain("$skiptoken=next-token");
|
|
1098
|
+
});
|
|
1099
|
+
it.each(["list-calendar-events-delta", "list-calendar-view-delta"])(
|
|
1100
|
+
"ignores stale mixed-case top values for %s",
|
|
1101
|
+
async (alias) => {
|
|
1102
|
+
mockEndpoints.push(makeEndpoint({ alias }));
|
|
1103
|
+
mockEndpointsJson = [makeConfig({ toolName: alias })];
|
|
1104
|
+
const graphClient = createMockGraphClient();
|
|
1105
|
+
const server = createMockServer();
|
|
1106
|
+
const { registerGraphTools } = await loadModule();
|
|
1107
|
+
registerGraphTools(server, graphClient);
|
|
1108
|
+
for (const key of ["top", "$top", "TOP", "$TOP", "$Top"]) {
|
|
1109
|
+
await server.tools.get(alias).handler({ [key]: 10 });
|
|
1110
|
+
}
|
|
1111
|
+
expect(graphClient.graphRequest).toHaveBeenCalledTimes(5);
|
|
1112
|
+
for (const call of graphClient.graphRequest.mock.calls) {
|
|
1113
|
+
expect(JSON.stringify(call)).not.toContain("$top");
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
);
|
|
1117
|
+
it.each(["list-joined-teams", "list-my-associated-teams"])(
|
|
1118
|
+
"rejects unsupported query options before Graph dispatch for %s",
|
|
1119
|
+
async (alias) => {
|
|
1120
|
+
mockEndpoints.push(makeEndpoint({ alias }));
|
|
1121
|
+
mockEndpointsJson = [makeConfig({ toolName: alias })];
|
|
1122
|
+
const graphClient = createMockGraphClient();
|
|
1123
|
+
const server = createMockServer();
|
|
1124
|
+
const { registerGraphTools } = await loadModule();
|
|
1125
|
+
registerGraphTools(server, graphClient);
|
|
1126
|
+
for (const key of ["top", "$top", "skiptoken", "$skiptoken", "select", "$select"]) {
|
|
1127
|
+
const value = key.replace("$", "") === "top" ? 100 : "next-token";
|
|
1128
|
+
const result = await server.tools.get(alias).handler({ [key]: value });
|
|
1129
|
+
expect(result.isError).toBe(true);
|
|
1130
|
+
}
|
|
1131
|
+
expect(graphClient.graphRequest).not.toHaveBeenCalled();
|
|
1132
|
+
}
|
|
1133
|
+
);
|
|
1134
|
+
it("rejects oversized chat pages before Graph dispatch, including dollar-prefixed input", async () => {
|
|
1135
|
+
mockEndpoints.push(makeEndpoint({ alias: "list-chats" }));
|
|
1136
|
+
mockEndpointsJson = [makeConfig({ toolName: "list-chats" })];
|
|
1137
|
+
const graphClient = createMockGraphClient();
|
|
1138
|
+
const server = createMockServer();
|
|
1139
|
+
const { registerGraphTools } = await loadModule();
|
|
1140
|
+
registerGraphTools(server, graphClient);
|
|
1141
|
+
for (const key of ["top", "$top"]) {
|
|
1142
|
+
const result = await server.tools.get("list-chats").handler({ [key]: 100 });
|
|
1143
|
+
expect(result.isError).toBe(true);
|
|
1144
|
+
}
|
|
1145
|
+
expect(graphClient.graphRequest).not.toHaveBeenCalled();
|
|
1146
|
+
});
|
|
1147
|
+
it("sends a calendar field array as comma-separated text in one Graph request", async () => {
|
|
1148
|
+
const alias = "list-calendar-events-delta";
|
|
1149
|
+
mockEndpoints.push(makeEndpoint({ alias }));
|
|
1150
|
+
mockEndpointsJson = [makeConfig({ toolName: alias })];
|
|
1151
|
+
const graphClient = createMockGraphClient();
|
|
1152
|
+
const server = createMockServer();
|
|
1153
|
+
const { registerGraphTools } = await loadModule();
|
|
1154
|
+
registerGraphTools(server, graphClient);
|
|
1155
|
+
await server.tools.get(alias).handler({ select: ["id", "subject"] });
|
|
1156
|
+
expect(graphClient.graphRequest).toHaveBeenCalledTimes(1);
|
|
1157
|
+
expect(JSON.stringify(graphClient.graphRequest.mock.calls[0])).toContain("id,subject");
|
|
1158
|
+
});
|
|
1159
|
+
});
|
|
1071
1160
|
describe("skiptoken cursor", () => {
|
|
1072
1161
|
const prevAllowPagination = process.env.MS365_MCP_ALLOW_PAGINATION;
|
|
1073
1162
|
afterEach(() => {
|
|
@@ -2098,6 +2187,8 @@ describe("graph-tools", () => {
|
|
|
2098
2187
|
const [path, options] = graphClient.graphRequest.mock.calls[0];
|
|
2099
2188
|
expect(path).toBe("/me/photo/$value");
|
|
2100
2189
|
expect(options.accessToken).toBeUndefined();
|
|
2190
|
+
expect(options.forceBinary).toBe(true);
|
|
2191
|
+
expect(options.rawResponse).toBe(true);
|
|
2101
2192
|
});
|
|
2102
2193
|
it("rejects absolute URLs (Graph paths only)", async () => {
|
|
2103
2194
|
mockEndpoints.length = 0;
|
package/dist/generated/client.js
CHANGED
|
@@ -846,8 +846,8 @@ const microsoft_graph_driveItem = z.object({
|
|
|
846
846
|
webUrl: z.string().describe(
|
|
847
847
|
"URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
|
|
848
848
|
).nullish(),
|
|
849
|
-
createdByUser: microsoft_graph_user.describe("[Note: Simplified from
|
|
850
|
-
lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from
|
|
849
|
+
createdByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
|
|
850
|
+
lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
|
|
851
851
|
audio: microsoft_graph_audio.optional(),
|
|
852
852
|
bundle: microsoft_graph_bundle.optional(),
|
|
853
853
|
cTag: z.string().describe(
|
|
@@ -2586,8 +2586,8 @@ const microsoft_graph_listItem = z.object({
|
|
|
2586
2586
|
webUrl: z.string().describe(
|
|
2587
2587
|
"URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
|
|
2588
2588
|
).nullish(),
|
|
2589
|
-
createdByUser: microsoft_graph_user.describe("[Note: Simplified from
|
|
2590
|
-
lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from
|
|
2589
|
+
createdByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
|
|
2590
|
+
lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
|
|
2591
2591
|
contentType: microsoft_graph_contentTypeInfo.optional(),
|
|
2592
2592
|
deleted: microsoft_graph_deleted.optional(),
|
|
2593
2593
|
sharepointIds: microsoft_graph_sharepointIds.optional(),
|
|
@@ -2708,8 +2708,8 @@ const microsoft_graph_list = z.lazy(
|
|
|
2708
2708
|
webUrl: z.string().describe(
|
|
2709
2709
|
"URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
|
|
2710
2710
|
).nullish(),
|
|
2711
|
-
createdByUser: microsoft_graph_user.describe("[Note: Simplified from
|
|
2712
|
-
lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from
|
|
2711
|
+
createdByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
|
|
2712
|
+
lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
|
|
2713
2713
|
displayName: z.string().describe("The displayable title of the list.").nullish(),
|
|
2714
2714
|
list: microsoft_graph_listInfo.optional(),
|
|
2715
2715
|
sharepointIds: microsoft_graph_sharepointIds.optional(),
|
|
@@ -2741,8 +2741,8 @@ const microsoft_graph_drive = z.lazy(
|
|
|
2741
2741
|
webUrl: z.string().describe(
|
|
2742
2742
|
"URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
|
|
2743
2743
|
).nullish(),
|
|
2744
|
-
createdByUser: microsoft_graph_user.describe("[Note: Simplified from
|
|
2745
|
-
lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from
|
|
2744
|
+
createdByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
|
|
2745
|
+
lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
|
|
2746
2746
|
driveType: z.string().describe(
|
|
2747
2747
|
"Describes the type of drive represented by this resource. OneDrive personal drives return personal. OneDrive for Business returns business. SharePoint document libraries return documentLibrary. Read-only."
|
|
2748
2748
|
).nullish(),
|
|
@@ -4558,8 +4558,8 @@ const microsoft_graph_baseItem = z.object({
|
|
|
4558
4558
|
webUrl: z.string().describe(
|
|
4559
4559
|
"URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
|
|
4560
4560
|
).nullish(),
|
|
4561
|
-
createdByUser: microsoft_graph_user.describe("[Note: Simplified from
|
|
4562
|
-
lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from
|
|
4561
|
+
createdByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
|
|
4562
|
+
lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional()
|
|
4563
4563
|
}).passthrough();
|
|
4564
4564
|
const microsoft_graph_site = z.object({
|
|
4565
4565
|
id: z.string().describe("The unique identifier for an entity. Read-only.").optional(),
|
|
@@ -4580,8 +4580,8 @@ const microsoft_graph_site = z.object({
|
|
|
4580
4580
|
webUrl: z.string().describe(
|
|
4581
4581
|
"URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
|
|
4582
4582
|
).nullish(),
|
|
4583
|
-
createdByUser: microsoft_graph_user.describe("[Note: Simplified from
|
|
4584
|
-
lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from
|
|
4583
|
+
createdByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
|
|
4584
|
+
lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
|
|
4585
4585
|
isPersonalSite: z.boolean().describe("Identifies whether the site is personal or not. Read-only.").nullish(),
|
|
4586
4586
|
root: microsoft_graph_root.optional(),
|
|
4587
4587
|
sharepointIds: microsoft_graph_sharepointIds.optional(),
|
|
@@ -5880,8 +5880,8 @@ const endpoints = makeApi([
|
|
|
5880
5880
|
webUrl: z.string().describe(
|
|
5881
5881
|
"URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
|
|
5882
5882
|
).nullish(),
|
|
5883
|
-
createdByUser: microsoft_graph_user.describe("[Note: Simplified from
|
|
5884
|
-
lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from
|
|
5883
|
+
createdByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
|
|
5884
|
+
lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
|
|
5885
5885
|
audio: microsoft_graph_audio.optional(),
|
|
5886
5886
|
bundle: microsoft_graph_bundle.optional(),
|
|
5887
5887
|
cTag: z.string().describe(
|
|
@@ -5995,8 +5995,8 @@ const endpoints = makeApi([
|
|
|
5995
5995
|
webUrl: z.string().describe(
|
|
5996
5996
|
"URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
|
|
5997
5997
|
).nullish(),
|
|
5998
|
-
createdByUser: microsoft_graph_user.describe("[Note: Simplified from
|
|
5999
|
-
lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from
|
|
5998
|
+
createdByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
|
|
5999
|
+
lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
|
|
6000
6000
|
audio: microsoft_graph_audio.optional(),
|
|
6001
6001
|
bundle: microsoft_graph_bundle.optional(),
|
|
6002
6002
|
cTag: z.string().describe(
|
package/dist/graph-client.js
CHANGED
|
@@ -11,6 +11,7 @@ import { applyMessageSignoffToRequest } from "./lib/message-signoff.js";
|
|
|
11
11
|
import { TRANSPORT_OK_MESSAGE } from "./lib/select-projection.js";
|
|
12
12
|
import { open, stat, unlink } from "fs/promises";
|
|
13
13
|
import { pipeline } from "stream/promises";
|
|
14
|
+
const UTF8_STRICT = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
14
15
|
function isBinaryContentType(contentType) {
|
|
15
16
|
if (!contentType) return false;
|
|
16
17
|
const lower = contentType.toLowerCase().split(";")[0].trim();
|
|
@@ -120,11 +121,20 @@ class GraphClient {
|
|
|
120
121
|
throw await this.createGraphApiError(response);
|
|
121
122
|
}
|
|
122
123
|
const contentTypeHeader = response.headers?.get?.("content-type") || "";
|
|
123
|
-
const isBinaryResponse = isBinaryContentType(contentTypeHeader);
|
|
124
|
+
const isBinaryResponse = options.forceBinary === true || isBinaryContentType(contentTypeHeader);
|
|
124
125
|
let metadata = { http_status: response.status };
|
|
125
126
|
let result;
|
|
126
|
-
|
|
127
|
-
|
|
127
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
128
|
+
metadata = { ...metadata, response_bytes: buffer.byteLength };
|
|
129
|
+
let text;
|
|
130
|
+
if (!isBinaryResponse) {
|
|
131
|
+
try {
|
|
132
|
+
text = UTF8_STRICT.decode(buffer);
|
|
133
|
+
} catch {
|
|
134
|
+
text = void 0;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (text === void 0) {
|
|
128
138
|
result = {
|
|
129
139
|
message: TRANSPORT_OK_MESSAGE,
|
|
130
140
|
contentType: contentTypeHeader,
|
|
@@ -132,20 +142,15 @@ class GraphClient {
|
|
|
132
142
|
contentLength: buffer.byteLength,
|
|
133
143
|
contentBytes: buffer.toString("base64")
|
|
134
144
|
};
|
|
135
|
-
|
|
145
|
+
} else if (text === "") {
|
|
146
|
+
result = { message: TRANSPORT_OK_MESSAGE };
|
|
147
|
+
} else if (options.rawResponse) {
|
|
148
|
+
result = { message: TRANSPORT_OK_MESSAGE, rawResponse: text };
|
|
136
149
|
} else {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
result = { message: TRANSPORT_OK_MESSAGE };
|
|
141
|
-
} else if (options.rawResponse) {
|
|
150
|
+
try {
|
|
151
|
+
result = JSON.parse(text.charCodeAt(0) === 65279 ? text.slice(1) : text);
|
|
152
|
+
} catch {
|
|
142
153
|
result = { message: TRANSPORT_OK_MESSAGE, rawResponse: text };
|
|
143
|
-
} else {
|
|
144
|
-
try {
|
|
145
|
-
result = JSON.parse(text);
|
|
146
|
-
} catch {
|
|
147
|
-
result = { message: TRANSPORT_OK_MESSAGE, rawResponse: text };
|
|
148
|
-
}
|
|
149
154
|
}
|
|
150
155
|
}
|
|
151
156
|
if (endpoint === "/$batch") {
|
package/dist/graph-tools.js
CHANGED
|
@@ -35,6 +35,7 @@ import { parseTeamsUrl } from "./lib/teams-url-parser.js";
|
|
|
35
35
|
import { buildBM25Index, scoreQuery, tokenize } from "./lib/bm25.js";
|
|
36
36
|
import { deriveTargetResource } from "./audit-target-resource.js";
|
|
37
37
|
import { describeToolSchema, describeUtilityToolSchema } from "./lib/tool-schema.js";
|
|
38
|
+
import { queryParameterSchema } from "./lib/query-parameter-schema.js";
|
|
38
39
|
import {
|
|
39
40
|
TOP_UNSUPPORTED_DELTA_TOOLS,
|
|
40
41
|
shouldOmitTopParam,
|
|
@@ -43,14 +44,6 @@ import {
|
|
|
43
44
|
DEFAULT_MAX_PAGES,
|
|
44
45
|
getMaxPages,
|
|
45
46
|
isFetchAllPagesApplicable,
|
|
46
|
-
FILTER_PARAM_DESCRIPTION,
|
|
47
|
-
SEARCH_PARAM_DESCRIPTION,
|
|
48
|
-
SELECT_PARAM_DESCRIPTION,
|
|
49
|
-
EXPAND_PARAM_DESCRIPTION,
|
|
50
|
-
ORDERBY_PARAM_DESCRIPTION,
|
|
51
|
-
TOP_PARAM_DESCRIPTION,
|
|
52
|
-
SKIP_PARAM_DESCRIPTION,
|
|
53
|
-
COUNT_PARAM_DESCRIPTION,
|
|
54
47
|
CONFIRM_PARAM_DESCRIPTION,
|
|
55
48
|
TIMEZONE_PARAM_DESCRIPTION,
|
|
56
49
|
EXPAND_EXTENDED_PROPERTIES_PARAM_DESCRIPTION,
|
|
@@ -686,7 +679,8 @@ const UTILITY_TOOLS = [
|
|
|
686
679
|
}
|
|
687
680
|
return await graphClient.graphRequest(target, {
|
|
688
681
|
accessToken: accountAccessToken,
|
|
689
|
-
rawResponse: true
|
|
682
|
+
rawResponse: true,
|
|
683
|
+
forceBinary: true
|
|
690
684
|
});
|
|
691
685
|
} catch (error) {
|
|
692
686
|
return {
|
|
@@ -1195,13 +1189,38 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
|
|
|
1195
1189
|
"search",
|
|
1196
1190
|
"format"
|
|
1197
1191
|
];
|
|
1198
|
-
const
|
|
1199
|
-
const isOdataParam = odataParams.includes(
|
|
1200
|
-
const
|
|
1192
|
+
const bareParamName = paramName.startsWith("$") ? paramName.slice(1) : paramName;
|
|
1193
|
+
const isOdataParam = odataParams.includes(bareParamName.toLowerCase());
|
|
1194
|
+
const normalizedParamName = isOdataParam ? bareParamName.toLowerCase() : bareParamName;
|
|
1195
|
+
const fixedParamName = isOdataParam ? `$${normalizedParamName}` : paramName;
|
|
1201
1196
|
const camelCaseParamName = paramName.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
1202
1197
|
const paramDef = parameterDefinitions.find(
|
|
1203
|
-
(p) => p.name === paramName || p.name === camelCaseParamName || isOdataParam && p.name === normalizedParamName
|
|
1198
|
+
(p) => p.name === paramName || p.name === camelCaseParamName || isOdataParam && p.name.replace(/^\$/, "").toLowerCase() === normalizedParamName
|
|
1204
1199
|
);
|
|
1200
|
+
const isQuery = paramDef?.type === "Query" || isOdataParam;
|
|
1201
|
+
const ignoredDeltaTop = shouldOmitTopParam(tool.alias) && normalizedParamName === "top";
|
|
1202
|
+
if (isQuery && !ignoredDeltaTop && paramValue != null && paramValue !== "") {
|
|
1203
|
+
const schema = queryParameterSchema(
|
|
1204
|
+
tool.alias,
|
|
1205
|
+
normalizedParamName,
|
|
1206
|
+
paramDef?.schema ?? z.any()
|
|
1207
|
+
);
|
|
1208
|
+
if (!schema || !schema.safeParse(paramValue).success) {
|
|
1209
|
+
return {
|
|
1210
|
+
content: [
|
|
1211
|
+
{
|
|
1212
|
+
type: "text",
|
|
1213
|
+
text: JSON.stringify({
|
|
1214
|
+
error: "invalid_query_parameter",
|
|
1215
|
+
parameter: fixedParamName,
|
|
1216
|
+
message: schema ? "Value does not match the query contract. Check get-tool-schema." : "This endpoint does not support this query parameter."
|
|
1217
|
+
})
|
|
1218
|
+
}
|
|
1219
|
+
],
|
|
1220
|
+
isError: true
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1205
1224
|
if (paramDef) {
|
|
1206
1225
|
switch (paramDef.type) {
|
|
1207
1226
|
case "Path": {
|
|
@@ -1632,6 +1651,11 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
|
|
|
1632
1651
|
const paramSchema = {};
|
|
1633
1652
|
if (tool.parameters && tool.parameters.length > 0) {
|
|
1634
1653
|
for (const param of tool.parameters) {
|
|
1654
|
+
if (param.type === "Query") {
|
|
1655
|
+
const schema = queryParameterSchema(tool.alias, param.name, param.schema || z.any());
|
|
1656
|
+
if (schema) paramSchema[param.name] = schema;
|
|
1657
|
+
continue;
|
|
1658
|
+
}
|
|
1635
1659
|
paramSchema[param.name] = param.type === "Body" && param.schema ? lenientBodySchema(param.schema) : param.schema || z.any();
|
|
1636
1660
|
}
|
|
1637
1661
|
}
|
|
@@ -1652,41 +1676,6 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
|
|
|
1652
1676
|
if (isSkiptokenApplicable(tool, Object.keys(paramSchema))) {
|
|
1653
1677
|
paramSchema["skiptoken"] = z.string().describe(SKIPTOKEN_PARAM_DESCRIPTION).optional();
|
|
1654
1678
|
}
|
|
1655
|
-
if (paramSchema["filter"] !== void 0 || paramSchema["$filter"] !== void 0) {
|
|
1656
|
-
const key = paramSchema["$filter"] !== void 0 ? "$filter" : "filter";
|
|
1657
|
-
paramSchema[key] = z.string().describe(FILTER_PARAM_DESCRIPTION).optional();
|
|
1658
|
-
}
|
|
1659
|
-
if (paramSchema["search"] !== void 0 || paramSchema["$search"] !== void 0) {
|
|
1660
|
-
const key = paramSchema["$search"] !== void 0 ? "$search" : "search";
|
|
1661
|
-
paramSchema[key] = z.string().describe(SEARCH_PARAM_DESCRIPTION).optional();
|
|
1662
|
-
}
|
|
1663
|
-
if (paramSchema["select"] !== void 0 || paramSchema["$select"] !== void 0) {
|
|
1664
|
-
const key = paramSchema["$select"] !== void 0 ? "$select" : "select";
|
|
1665
|
-
paramSchema[key] = z.string().describe(SELECT_PARAM_DESCRIPTION).optional();
|
|
1666
|
-
}
|
|
1667
|
-
if (paramSchema["expand"] !== void 0 || paramSchema["$expand"] !== void 0) {
|
|
1668
|
-
const key = paramSchema["$expand"] !== void 0 ? "$expand" : "expand";
|
|
1669
|
-
paramSchema[key] = z.array(z.string()).describe(EXPAND_PARAM_DESCRIPTION).optional();
|
|
1670
|
-
}
|
|
1671
|
-
if (paramSchema["orderby"] !== void 0 || paramSchema["$orderby"] !== void 0) {
|
|
1672
|
-
const key = paramSchema["$orderby"] !== void 0 ? "$orderby" : "orderby";
|
|
1673
|
-
paramSchema[key] = z.string().describe(ORDERBY_PARAM_DESCRIPTION).optional();
|
|
1674
|
-
}
|
|
1675
|
-
if (shouldOmitTopParam(tool.alias)) {
|
|
1676
|
-
delete paramSchema["top"];
|
|
1677
|
-
delete paramSchema["$top"];
|
|
1678
|
-
} else if (paramSchema["top"] !== void 0 || paramSchema["$top"] !== void 0) {
|
|
1679
|
-
const key = paramSchema["$top"] !== void 0 ? "$top" : "top";
|
|
1680
|
-
paramSchema[key] = z.number().describe(TOP_PARAM_DESCRIPTION).optional();
|
|
1681
|
-
}
|
|
1682
|
-
if (paramSchema["skip"] !== void 0 || paramSchema["$skip"] !== void 0) {
|
|
1683
|
-
const key = paramSchema["$skip"] !== void 0 ? "$skip" : "skip";
|
|
1684
|
-
paramSchema[key] = z.number().describe(SKIP_PARAM_DESCRIPTION).optional();
|
|
1685
|
-
}
|
|
1686
|
-
if (paramSchema["count"] !== void 0 || paramSchema["$count"] !== void 0) {
|
|
1687
|
-
const countKey = paramSchema["$count"] !== void 0 ? "$count" : "count";
|
|
1688
|
-
paramSchema[countKey] = z.boolean().describe(COUNT_PARAM_DESCRIPTION).optional();
|
|
1689
|
-
}
|
|
1690
1679
|
if (multiAccount) {
|
|
1691
1680
|
paramSchema["account"] = z.string().describe(getAccountParamDescription(accountNames)).optional();
|
|
1692
1681
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getODataParamDescription, shouldOmitTopParam } from "./param-descriptions.js";
|
|
3
|
+
const TEAM_LIST_TOOLS = /* @__PURE__ */ new Set(["list-joined-teams", "list-my-associated-teams"]);
|
|
4
|
+
function queryParameterSchema(toolName, name, providerSchema) {
|
|
5
|
+
const bareName = name.replace(/^\$/, "").toLowerCase();
|
|
6
|
+
if (TEAM_LIST_TOOLS.has(toolName)) return void 0;
|
|
7
|
+
if (bareName === "top" && shouldOmitTopParam(toolName)) return void 0;
|
|
8
|
+
const source = providerSchema instanceof z.ZodOptional ? providerSchema.unwrap() : providerSchema;
|
|
9
|
+
let schema = source;
|
|
10
|
+
switch (bareName) {
|
|
11
|
+
case "select":
|
|
12
|
+
case "expand":
|
|
13
|
+
case "orderby":
|
|
14
|
+
schema = z.union([z.string(), z.array(z.string())]);
|
|
15
|
+
break;
|
|
16
|
+
case "filter":
|
|
17
|
+
case "search":
|
|
18
|
+
schema = z.string();
|
|
19
|
+
break;
|
|
20
|
+
case "skiptoken":
|
|
21
|
+
schema = z.string();
|
|
22
|
+
break;
|
|
23
|
+
case "top":
|
|
24
|
+
if (toolName === "list-chats") schema = source.and(z.number().max(50));
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
if (providerSchema.isOptional()) schema = schema.optional();
|
|
28
|
+
const description = getODataParamDescription(bareName);
|
|
29
|
+
return description ? schema.describe(description) : schema;
|
|
30
|
+
}
|
|
31
|
+
export {
|
|
32
|
+
queryParameterSchema
|
|
33
|
+
};
|
package/dist/lib/tool-schema.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
2
|
+
import { queryParameterSchema } from "./query-parameter-schema.js";
|
|
2
3
|
import { isDestructiveOperation } from "./destructive-ops.js";
|
|
3
4
|
import {
|
|
4
|
-
getODataParamDescription,
|
|
5
|
-
shouldOmitTopParam,
|
|
6
5
|
isFetchAllPagesApplicable,
|
|
7
6
|
isSkiptokenApplicable,
|
|
8
7
|
SKIPTOKEN_PARAM_DESCRIPTION,
|
|
@@ -22,24 +21,23 @@ function unwrapOptional(schema) {
|
|
|
22
21
|
}
|
|
23
22
|
return { inner: schema, optional: false };
|
|
24
23
|
}
|
|
25
|
-
function bareParamName(name) {
|
|
26
|
-
return name.startsWith("$") ? name.slice(1) : name;
|
|
27
|
-
}
|
|
28
24
|
function describeToolSchema(tool, config, ctx = {}) {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
const params = (tool.parameters ?? []).flatMap((p) => {
|
|
26
|
+
const effectiveSchema = p.type === "Query" ? queryParameterSchema(tool.alias, p.name, p.schema) : p.schema;
|
|
27
|
+
if (!effectiveSchema) return [];
|
|
28
|
+
const { inner, optional } = unwrapOptional(effectiveSchema);
|
|
32
29
|
const isPath = p.type === "Path";
|
|
33
30
|
const jsonSchema = zodToJsonSchema(inner, { target: "jsonSchema7", $refStrategy: "none" });
|
|
34
31
|
const { $schema: _s, ...schema } = jsonSchema;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
32
|
+
return [
|
|
33
|
+
{
|
|
34
|
+
name: p.name,
|
|
35
|
+
in: p.type,
|
|
36
|
+
required: isPath || !optional,
|
|
37
|
+
description: effectiveSchema.description ?? p.description,
|
|
38
|
+
schema
|
|
39
|
+
}
|
|
40
|
+
];
|
|
43
41
|
});
|
|
44
42
|
if (isDestructiveOperation(tool.method, config)) {
|
|
45
43
|
params.push({
|
package/docs/deployment.md
CHANGED
|
@@ -28,7 +28,25 @@ In HTTP mode, `MS365_MCP_AUTH_CACHE_COMMAND` is skipped at startup and per Graph
|
|
|
28
28
|
|
|
29
29
|
## Docker
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
Released images are published to GitHub Container Registry:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
docker pull ghcr.io/softeria/ms-365-mcp-server:latest
|
|
35
|
+
# or pin a release
|
|
36
|
+
docker pull ghcr.io/softeria/ms-365-mcp-server:<version>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The image runs as a non-root user and takes the same arguments as the CLI:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
docker run -p 3000:3000 \
|
|
43
|
+
-e MS365_MCP_CLIENT_ID=your-client-id \
|
|
44
|
+
-e MS365_MCP_TENANT_ID=your-tenant-id \
|
|
45
|
+
ghcr.io/softeria/ms-365-mcp-server:latest \
|
|
46
|
+
--http 3000 --org-mode
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
A `Dockerfile` is also included, to build from source instead:
|
|
32
50
|
|
|
33
51
|
```bash
|
|
34
52
|
# Build the image
|
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.
|
|
4
|
+
"version": "0.154.2",
|
|
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",
|