@softeria/ms-365-mcp-server 0.144.0 → 0.145.1
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/dist/__tests__/graph-tools.test.js +253 -0
- package/dist/endpoints.json +5 -5
- package/dist/generated/client.js +3 -1
- package/dist/graph-tools.js +102 -0
- package/dist/mcp-instructions.js +1 -1
- package/docs/deployment.md +1 -1
- package/package.json +1 -1
- package/src/endpoints.json +5 -5
|
@@ -100,10 +100,33 @@ async function loadModule() {
|
|
|
100
100
|
}
|
|
101
101
|
function createMockServer() {
|
|
102
102
|
const tools = /* @__PURE__ */ new Map();
|
|
103
|
+
const requestHandlers = /* @__PURE__ */ new Map();
|
|
104
|
+
const installDefaultToolCallHandler = () => {
|
|
105
|
+
if (requestHandlers.has("tools/call")) return;
|
|
106
|
+
requestHandlers.set("tools/call", async (request) => {
|
|
107
|
+
const params = request.params;
|
|
108
|
+
const toolName = params?.name ?? "unknown";
|
|
109
|
+
const tool = tools.get(toolName);
|
|
110
|
+
if (!tool) {
|
|
111
|
+
throw new Error(`Tool ${toolName} not found`);
|
|
112
|
+
}
|
|
113
|
+
return tool.handler(params?.arguments ?? {});
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
const lowLevelServer = {
|
|
117
|
+
_requestHandlers: requestHandlers,
|
|
118
|
+
setRequestHandler: vi.fn(
|
|
119
|
+
(_schema, handler) => {
|
|
120
|
+
requestHandlers.set("tools/call", handler);
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
};
|
|
103
124
|
return {
|
|
125
|
+
server: lowLevelServer,
|
|
104
126
|
tool: vi.fn(
|
|
105
127
|
(name, description, schema, annotations, handler) => {
|
|
106
128
|
tools.set(name, { description, schema, handler });
|
|
129
|
+
installDefaultToolCallHandler();
|
|
107
130
|
}
|
|
108
131
|
),
|
|
109
132
|
registerTool: vi.fn(
|
|
@@ -113,6 +136,7 @@ function createMockServer() {
|
|
|
113
136
|
schema: config.inputSchema?.shape ?? config.inputSchema,
|
|
114
137
|
handler
|
|
115
138
|
});
|
|
139
|
+
installDefaultToolCallHandler();
|
|
116
140
|
}
|
|
117
141
|
),
|
|
118
142
|
tools
|
|
@@ -1817,6 +1841,65 @@ describe("graph-tools", () => {
|
|
|
1817
1841
|
expect(server.tools.has("list-mail-messages")).toBe(true);
|
|
1818
1842
|
expect(server.tools.has("list-calendar-events")).toBe(false);
|
|
1819
1843
|
});
|
|
1844
|
+
it("audits direct calls to Graph tools denied by allowed scopes", async () => {
|
|
1845
|
+
mockEndpoints.push({
|
|
1846
|
+
alias: "get-drive-item",
|
|
1847
|
+
method: "get",
|
|
1848
|
+
path: "/drives/:driveId/items/:driveItemId",
|
|
1849
|
+
description: "Get drive item",
|
|
1850
|
+
parameters: [
|
|
1851
|
+
{ name: "driveId", type: "Path", schema: z.string() },
|
|
1852
|
+
{ name: "driveItemId", type: "Path", schema: z.string() }
|
|
1853
|
+
]
|
|
1854
|
+
});
|
|
1855
|
+
mockEndpointsJson = [
|
|
1856
|
+
{
|
|
1857
|
+
toolName: "get-drive-item",
|
|
1858
|
+
method: "get",
|
|
1859
|
+
pathPattern: "/drives/{drive-id}/items/{driveItem-id}",
|
|
1860
|
+
scopes: ["Files.Read"]
|
|
1861
|
+
}
|
|
1862
|
+
];
|
|
1863
|
+
const server = createMockServer();
|
|
1864
|
+
const { registerGraphTools } = await loadModule();
|
|
1865
|
+
registerGraphTools(
|
|
1866
|
+
server,
|
|
1867
|
+
createMockGraphClient(),
|
|
1868
|
+
false,
|
|
1869
|
+
void 0,
|
|
1870
|
+
false,
|
|
1871
|
+
void 0,
|
|
1872
|
+
false,
|
|
1873
|
+
[],
|
|
1874
|
+
"Mail.Read"
|
|
1875
|
+
);
|
|
1876
|
+
const handler = server.server._requestHandlers.get("tools/call");
|
|
1877
|
+
await expect(
|
|
1878
|
+
handler?.(
|
|
1879
|
+
{
|
|
1880
|
+
method: "tools/call",
|
|
1881
|
+
params: {
|
|
1882
|
+
name: "get-drive-item",
|
|
1883
|
+
arguments: { driveId: "drive-1", driveItemId: "item-2" }
|
|
1884
|
+
}
|
|
1885
|
+
},
|
|
1886
|
+
{}
|
|
1887
|
+
)
|
|
1888
|
+
).rejects.toThrow(/not found/);
|
|
1889
|
+
expect(auditLogMock).toHaveBeenCalledWith(
|
|
1890
|
+
expect.objectContaining({
|
|
1891
|
+
event: "tool.denied",
|
|
1892
|
+
tool: "get-drive-item",
|
|
1893
|
+
status: "denied",
|
|
1894
|
+
reason: "allowed_scopes",
|
|
1895
|
+
missing_scopes: ["Files.Read"],
|
|
1896
|
+
target_resource: {
|
|
1897
|
+
type: "drive_item",
|
|
1898
|
+
id: "/drives/drive-1/items/item-2"
|
|
1899
|
+
}
|
|
1900
|
+
})
|
|
1901
|
+
);
|
|
1902
|
+
});
|
|
1820
1903
|
it("discovery hides Graph tools outside the allowed scopes", async () => {
|
|
1821
1904
|
mockEndpoints.push(
|
|
1822
1905
|
{
|
|
@@ -1866,6 +1949,117 @@ describe("graph-tools", () => {
|
|
|
1866
1949
|
expect(found).toContain("list-mail-messages");
|
|
1867
1950
|
expect(found).not.toContain("list-calendar-events");
|
|
1868
1951
|
});
|
|
1952
|
+
it("audits execute-tool attempts denied by allowed scopes", async () => {
|
|
1953
|
+
mockEndpoints.push({
|
|
1954
|
+
alias: "get-drive-item",
|
|
1955
|
+
method: "get",
|
|
1956
|
+
path: "/drives/:driveId/items/:driveItemId",
|
|
1957
|
+
description: "Get drive item",
|
|
1958
|
+
parameters: [
|
|
1959
|
+
{ name: "driveId", type: "Path", schema: z.string() },
|
|
1960
|
+
{ name: "driveItemId", type: "Path", schema: z.string() }
|
|
1961
|
+
]
|
|
1962
|
+
});
|
|
1963
|
+
mockEndpointsJson = [
|
|
1964
|
+
{
|
|
1965
|
+
toolName: "get-drive-item",
|
|
1966
|
+
method: "get",
|
|
1967
|
+
pathPattern: "/drives/{drive-id}/items/{driveItem-id}",
|
|
1968
|
+
scopes: ["Files.Read"]
|
|
1969
|
+
}
|
|
1970
|
+
];
|
|
1971
|
+
const server = createMockServer();
|
|
1972
|
+
const { registerDiscoveryTools } = await loadModule();
|
|
1973
|
+
registerDiscoveryTools(
|
|
1974
|
+
server,
|
|
1975
|
+
{},
|
|
1976
|
+
false,
|
|
1977
|
+
false,
|
|
1978
|
+
void 0,
|
|
1979
|
+
false,
|
|
1980
|
+
[],
|
|
1981
|
+
void 0,
|
|
1982
|
+
"Mail.Read"
|
|
1983
|
+
);
|
|
1984
|
+
const result = await server.tools.get("execute-tool").handler({
|
|
1985
|
+
tool_name: "get-drive-item",
|
|
1986
|
+
parameters: { driveId: "drive-1", driveItemId: "item-2" }
|
|
1987
|
+
});
|
|
1988
|
+
expect(result.isError).toBe(true);
|
|
1989
|
+
expect(JSON.parse(result.content[0].text).error).toMatch(/not found/i);
|
|
1990
|
+
expect(auditLogMock).toHaveBeenCalledWith(
|
|
1991
|
+
expect.objectContaining({
|
|
1992
|
+
event: "tool.denied",
|
|
1993
|
+
tool: "get-drive-item",
|
|
1994
|
+
status: "denied",
|
|
1995
|
+
reason: "allowed_scopes",
|
|
1996
|
+
missing_scopes: ["Files.Read"],
|
|
1997
|
+
target_resource: {
|
|
1998
|
+
type: "drive_item",
|
|
1999
|
+
id: "/drives/drive-1/items/item-2"
|
|
2000
|
+
}
|
|
2001
|
+
})
|
|
2002
|
+
);
|
|
2003
|
+
});
|
|
2004
|
+
it("audits direct discovery-mode calls to Graph tools denied by allowed scopes", async () => {
|
|
2005
|
+
mockEndpoints.push({
|
|
2006
|
+
alias: "get-drive-item",
|
|
2007
|
+
method: "get",
|
|
2008
|
+
path: "/drives/:driveId/items/:driveItemId",
|
|
2009
|
+
description: "Get drive item",
|
|
2010
|
+
parameters: [
|
|
2011
|
+
{ name: "driveId", type: "Path", schema: z.string() },
|
|
2012
|
+
{ name: "driveItemId", type: "Path", schema: z.string() }
|
|
2013
|
+
]
|
|
2014
|
+
});
|
|
2015
|
+
mockEndpointsJson = [
|
|
2016
|
+
{
|
|
2017
|
+
toolName: "get-drive-item",
|
|
2018
|
+
method: "get",
|
|
2019
|
+
pathPattern: "/drives/{drive-id}/items/{driveItem-id}",
|
|
2020
|
+
scopes: ["Files.Read"]
|
|
2021
|
+
}
|
|
2022
|
+
];
|
|
2023
|
+
const server = createMockServer();
|
|
2024
|
+
const { registerDiscoveryTools } = await loadModule();
|
|
2025
|
+
registerDiscoveryTools(
|
|
2026
|
+
server,
|
|
2027
|
+
{},
|
|
2028
|
+
false,
|
|
2029
|
+
false,
|
|
2030
|
+
void 0,
|
|
2031
|
+
false,
|
|
2032
|
+
[],
|
|
2033
|
+
void 0,
|
|
2034
|
+
"Mail.Read"
|
|
2035
|
+
);
|
|
2036
|
+
const handler = server.server._requestHandlers.get("tools/call");
|
|
2037
|
+
await expect(
|
|
2038
|
+
handler?.(
|
|
2039
|
+
{
|
|
2040
|
+
method: "tools/call",
|
|
2041
|
+
params: {
|
|
2042
|
+
name: "get-drive-item",
|
|
2043
|
+
arguments: { driveId: "drive-1", driveItemId: "item-2" }
|
|
2044
|
+
}
|
|
2045
|
+
},
|
|
2046
|
+
{}
|
|
2047
|
+
)
|
|
2048
|
+
).rejects.toThrow(/not found/);
|
|
2049
|
+
expect(auditLogMock).toHaveBeenCalledWith(
|
|
2050
|
+
expect.objectContaining({
|
|
2051
|
+
event: "tool.denied",
|
|
2052
|
+
tool: "get-drive-item",
|
|
2053
|
+
status: "denied",
|
|
2054
|
+
reason: "allowed_scopes",
|
|
2055
|
+
missing_scopes: ["Files.Read"],
|
|
2056
|
+
target_resource: {
|
|
2057
|
+
type: "drive_item",
|
|
2058
|
+
id: "/drives/drive-1/items/item-2"
|
|
2059
|
+
}
|
|
2060
|
+
})
|
|
2061
|
+
);
|
|
2062
|
+
});
|
|
1869
2063
|
});
|
|
1870
2064
|
describe("discovery mode: utility tools", () => {
|
|
1871
2065
|
it('search-tools surfaces download-bytes for "download" queries', async () => {
|
|
@@ -1970,6 +2164,65 @@ describe("graph-tools", () => {
|
|
|
1970
2164
|
expect(found).toContain("list-mail-messages");
|
|
1971
2165
|
expect(found).not.toContain("list-calendar-events");
|
|
1972
2166
|
});
|
|
2167
|
+
it("audits execute-tool attempts denied by the enabled-tools allow-list", async () => {
|
|
2168
|
+
mockEndpoints.push(
|
|
2169
|
+
{
|
|
2170
|
+
alias: "get-drive-item",
|
|
2171
|
+
method: "get",
|
|
2172
|
+
path: "/drives/:driveId/items/:driveItemId",
|
|
2173
|
+
description: "Get drive item",
|
|
2174
|
+
parameters: [
|
|
2175
|
+
{ name: "driveId", type: "Path", schema: z.string() },
|
|
2176
|
+
{ name: "driveItemId", type: "Path", schema: z.string() }
|
|
2177
|
+
]
|
|
2178
|
+
},
|
|
2179
|
+
{
|
|
2180
|
+
alias: "list-mail-messages",
|
|
2181
|
+
method: "get",
|
|
2182
|
+
path: "/me/messages",
|
|
2183
|
+
description: "List mail",
|
|
2184
|
+
parameters: []
|
|
2185
|
+
}
|
|
2186
|
+
);
|
|
2187
|
+
mockEndpointsJson = [
|
|
2188
|
+
{
|
|
2189
|
+
toolName: "get-drive-item",
|
|
2190
|
+
method: "get",
|
|
2191
|
+
pathPattern: "/drives/{drive-id}/items/{driveItem-id}"
|
|
2192
|
+
},
|
|
2193
|
+
{ toolName: "list-mail-messages", method: "get", pathPattern: "/me/messages" }
|
|
2194
|
+
];
|
|
2195
|
+
const server = createMockServer();
|
|
2196
|
+
const { registerDiscoveryTools } = await loadModule();
|
|
2197
|
+
registerDiscoveryTools(
|
|
2198
|
+
server,
|
|
2199
|
+
{},
|
|
2200
|
+
false,
|
|
2201
|
+
false,
|
|
2202
|
+
void 0,
|
|
2203
|
+
false,
|
|
2204
|
+
[],
|
|
2205
|
+
"^list-mail-messages$"
|
|
2206
|
+
);
|
|
2207
|
+
const result = await server.tools.get("execute-tool").handler({
|
|
2208
|
+
tool_name: "get-drive-item",
|
|
2209
|
+
parameters: { driveId: "drive-1", driveItemId: "item-2" }
|
|
2210
|
+
});
|
|
2211
|
+
expect(result.isError).toBe(true);
|
|
2212
|
+
expect(JSON.parse(result.content[0].text).error).toMatch(/not found/i);
|
|
2213
|
+
expect(auditLogMock).toHaveBeenCalledWith(
|
|
2214
|
+
expect.objectContaining({
|
|
2215
|
+
event: "tool.denied",
|
|
2216
|
+
tool: "get-drive-item",
|
|
2217
|
+
status: "denied",
|
|
2218
|
+
reason: "tool_allowlist",
|
|
2219
|
+
target_resource: {
|
|
2220
|
+
type: "drive_item",
|
|
2221
|
+
id: "/drives/drive-1/items/item-2"
|
|
2222
|
+
}
|
|
2223
|
+
})
|
|
2224
|
+
);
|
|
2225
|
+
});
|
|
1973
2226
|
it("utility tools obey the regex too", async () => {
|
|
1974
2227
|
mockEndpoints.length = 0;
|
|
1975
2228
|
mockEndpointsJson = [];
|
package/dist/endpoints.json
CHANGED
|
@@ -212,7 +212,7 @@
|
|
|
212
212
|
"toolName": "add-mail-attachment",
|
|
213
213
|
"presets": ["mail", "outlook", "personal"],
|
|
214
214
|
"scopes": ["Mail.ReadWrite"],
|
|
215
|
-
"llmTip": "
|
|
215
|
+
"llmTip": "The only path for attachments under 3MB. contentBytes must carry the complete base64 verbatim; a truncated argument fails with 400 UnableToDeserializePostBody. At 3MB and above use create-mail-attachment-upload-session, which rejects anything smaller. Body requires @odata.type: {\"@odata.type\": \"#microsoft.graph.fileAttachment\", \"name\": \"file.pdf\", \"contentBytes\": \"<base64>\"}."
|
|
216
216
|
},
|
|
217
217
|
{
|
|
218
218
|
"pathPattern": "/me/messages/{message-id}/attachments/createUploadSession",
|
|
@@ -220,7 +220,7 @@
|
|
|
220
220
|
"toolName": "create-mail-attachment-upload-session",
|
|
221
221
|
"presets": ["mail", "outlook", "personal"],
|
|
222
222
|
"scopes": ["Mail.ReadWrite"],
|
|
223
|
-
"llmTip": "For
|
|
223
|
+
"llmTip": "For attachments 3MB to 150MB. Graph rejects smaller files with ErrorAttachmentSizeShouldNotBeLessThanMinimumSize, so under 3MB use add-mail-attachment instead. Body: { AttachmentItem: { attachmentType: 'file', name: 'report.pdf', size: 5000000 } }. Returns a pre-authenticated uploadUrl; the caller PUTs the bytes there itself in ranges up to 4MB. This server does not perform the PUT."
|
|
224
224
|
},
|
|
225
225
|
{
|
|
226
226
|
"pathPattern": "/me/messages/{message-id}/attachments",
|
|
@@ -607,7 +607,7 @@
|
|
|
607
607
|
"toolName": "upload-file-content",
|
|
608
608
|
"presets": ["files", "onedrive", "personal"],
|
|
609
609
|
"scopes": ["Files.ReadWrite"],
|
|
610
|
-
"llmTip": "Body is a base64-encoded string of the file bytes; the server decodes it before PUT.
|
|
610
|
+
"llmTip": "Body is a base64-encoded string of the file bytes; the server decodes it before PUT. Graph accepts up to 250MB here, but the whole string travels as a tool argument and a truncated one decodes to a truncated file with no error, so use create-upload-session rather than emitting a large base64 string. For new files use path format: /items/root:/path/to/file.txt:/content. Overwrites existing files without warning."
|
|
611
611
|
},
|
|
612
612
|
{
|
|
613
613
|
"pathPattern": "/drives/{drive-id}/items/{driveItem-id}/createUploadSession",
|
|
@@ -615,7 +615,7 @@
|
|
|
615
615
|
"toolName": "create-upload-session",
|
|
616
616
|
"presets": ["files", "onedrive", "personal"],
|
|
617
617
|
"scopes": ["Files.ReadWrite"],
|
|
618
|
-
"llmTip": "For large file uploads (no size limit). Returns a pre-authenticated uploadUrl
|
|
618
|
+
"llmTip": "For large file uploads (no size limit, and no minimum unlike the Outlook attachment session). Returns a pre-authenticated uploadUrl; the caller PUTs the bytes there itself. This server does not perform the PUT. For new files use path: /items/{parentId}:/{fileName}:/createUploadSession. Body (optional): { item: { '@microsoft.graph.conflictBehavior': 'rename' } }."
|
|
619
619
|
},
|
|
620
620
|
{
|
|
621
621
|
"pathPattern": "/drives/{drive-id}/items/{driveItem-id}",
|
|
@@ -2609,7 +2609,7 @@
|
|
|
2609
2609
|
"presets": ["personal"],
|
|
2610
2610
|
"scopes": ["User.ReadWrite"],
|
|
2611
2611
|
"contentType": "image/jpeg",
|
|
2612
|
-
"llmTip": "Uploads a new profile photo for the signed-in user. Body is a base64-encoded string of the image bytes (the server decodes before PUT). Photo must be JPEG, max 4 MB. Microsoft 365 generates HD downsized variants automatically (48x48, 64x64, 96x96, 120x120, 240x240, 360x360, 432x432, 504x504, 648x648). For work or school accounts, ProfilePhoto.ReadWrite.All is the more granular alternative permission. Use download-bytes with target=/me/photo/$value to retrieve the current photo."
|
|
2612
|
+
"llmTip": "Uploads a new profile photo for the signed-in user. Body is a base64-encoded string of the image bytes (the server decodes before PUT). Photo must be JPEG, max 4 MB; the base64 travels as a tool argument and a truncated one is written without error, so resize before encoding instead of emitting a large string. Microsoft 365 generates HD downsized variants automatically (48x48, 64x64, 96x96, 120x120, 240x240, 360x360, 432x432, 504x504, 648x648). For work or school accounts, ProfilePhoto.ReadWrite.All is the more granular alternative permission. Use download-bytes with target=/me/photo/$value to retrieve the current photo."
|
|
2613
2613
|
},
|
|
2614
2614
|
{
|
|
2615
2615
|
"pathPattern": "/me/todo/lists",
|
package/dist/generated/client.js
CHANGED
|
@@ -448,7 +448,9 @@ const microsoft_graph_chat = z.object({
|
|
|
448
448
|
permissionGrants: z.array(microsoft_graph_resourceSpecificPermissionGrant).describe("A collection of permissions granted to apps for the chat.").optional(),
|
|
449
449
|
pinnedMessages: z.array(microsoft_graph_pinnedChatMessageInfo).describe("A collection of all the pinned messages in the chat. Nullable.").optional(),
|
|
450
450
|
tabs: z.array(microsoft_graph_teamsTab).describe("A collection of all the tabs in the chat. Nullable.").optional(),
|
|
451
|
-
targetedMessages: z.array(microsoft_graph_targetedChatMessage).
|
|
451
|
+
targetedMessages: z.array(microsoft_graph_targetedChatMessage).describe(
|
|
452
|
+
"A collection of targeted messages in the chat that are visible only to specific users. Nullable. You can't expand this relationship using $expand. Targeted messages can also be retrieved via the userTeamwork: getAllTargetedMessages API."
|
|
453
|
+
).optional()
|
|
452
454
|
}).passthrough();
|
|
453
455
|
const microsoft_graph_ODataErrors_ErrorDetails = z.object({ code: z.string(), message: z.string(), target: z.string().nullish() }).passthrough();
|
|
454
456
|
const microsoft_graph_ODataErrors_InnerError = z.object({
|
package/dist/graph-tools.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
1
2
|
import { randomUUID } from "crypto";
|
|
2
3
|
import logger from "./logger.js";
|
|
3
4
|
import { auditLog, getUserIdentityForAudit } from "./audit-log.js";
|
|
@@ -161,6 +162,87 @@ function formatDisabledToolsForLog(disabledTools) {
|
|
|
161
162
|
const suffix = disabledTools.length > shown.length ? `, ... +${disabledTools.length - shown.length} more` : "";
|
|
162
163
|
return `${shown.join("; ")}${suffix}`;
|
|
163
164
|
}
|
|
165
|
+
function deniedToolPolicyForGraphTool(tool, config, reason, missingScopes) {
|
|
166
|
+
return {
|
|
167
|
+
toolName: tool.alias,
|
|
168
|
+
reason,
|
|
169
|
+
...missingScopes && missingScopes.length > 0 ? { missingScopes } : {},
|
|
170
|
+
pathPattern: config?.pathPattern ?? tool.path
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function collectDeniedToolPolicies(options) {
|
|
174
|
+
const deniedTools = /* @__PURE__ */ new Map();
|
|
175
|
+
const allowedScopes = parseAllowedScopes(options.allowedScopesValue);
|
|
176
|
+
for (const tool of allEndpoints) {
|
|
177
|
+
const endpointConfig = endpointsData.find((e) => e.toolName === tool.alias);
|
|
178
|
+
if (!options.orgMode && endpointConfig && !endpointConfig.scopes && endpointConfig.workScopes) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const method = tool.method.toUpperCase();
|
|
182
|
+
if (options.readOnly && method !== "GET" && !(method === "POST" && endpointConfig?.readOnly)) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (options.enabledToolsRegex && !options.enabledToolsRegex.test(tool.alias)) {
|
|
186
|
+
deniedTools.set(
|
|
187
|
+
tool.alias,
|
|
188
|
+
deniedToolPolicyForGraphTool(tool, endpointConfig, "tool_allowlist")
|
|
189
|
+
);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const missingScopes = allowedScopes !== void 0 && !endpointConfig ? ["endpoint scope metadata"] : getMissingAllowedScopesForGroups(
|
|
193
|
+
getEndpointScopeGroups(endpointConfig, options.orgMode),
|
|
194
|
+
allowedScopes
|
|
195
|
+
);
|
|
196
|
+
if (missingScopes.length > 0) {
|
|
197
|
+
deniedTools.set(
|
|
198
|
+
tool.alias,
|
|
199
|
+
deniedToolPolicyForGraphTool(tool, endpointConfig, "allowed_scopes", missingScopes)
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
for (const utility of UTILITY_TOOLS) {
|
|
204
|
+
if (options.readOnly && !utility.readOnlyHint) continue;
|
|
205
|
+
if (options.httpMode && utility.stdioOnly) continue;
|
|
206
|
+
if (options.enabledToolsRegex && !options.enabledToolsRegex.test(utility.name)) {
|
|
207
|
+
deniedTools.set(utility.name, {
|
|
208
|
+
toolName: utility.name,
|
|
209
|
+
reason: "tool_allowlist"
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return deniedTools;
|
|
214
|
+
}
|
|
215
|
+
function auditToolDenied(policy, params = {}) {
|
|
216
|
+
const targetResource = policy.pathPattern ? deriveTargetResource({ pathPattern: policy.pathPattern, params }) : void 0;
|
|
217
|
+
auditLog({
|
|
218
|
+
event: "tool.denied",
|
|
219
|
+
request_id: randomUUID(),
|
|
220
|
+
user_principal_name: getUserIdentityForAudit(getRequestTokens()?.accessToken),
|
|
221
|
+
tool: policy.toolName,
|
|
222
|
+
status: "denied",
|
|
223
|
+
reason: policy.reason,
|
|
224
|
+
...policy.missingScopes ? { missing_scopes: policy.missingScopes } : {},
|
|
225
|
+
...targetResource ? { target_resource: targetResource } : {}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
function installDeniedToolAuditHandler(server, deniedTools) {
|
|
229
|
+
if (deniedTools.size === 0) return;
|
|
230
|
+
const lowLevel = server.server;
|
|
231
|
+
const handlers = lowLevel._requestHandlers;
|
|
232
|
+
const original = handlers?.get("tools/call");
|
|
233
|
+
if (!original) {
|
|
234
|
+
logger.warn("Skipping denied-tool audit hook: tools/call handler not found");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
lowLevel.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
238
|
+
const policy = deniedTools.get(request.params.name);
|
|
239
|
+
if (policy) {
|
|
240
|
+
const params = request.params.arguments && typeof request.params.arguments === "object" ? request.params.arguments : {};
|
|
241
|
+
auditToolDenied(policy, params);
|
|
242
|
+
}
|
|
243
|
+
return original(request, extra);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
164
246
|
async function checkAccountParamInBearerMode(accountParam, authManager) {
|
|
165
247
|
if (!accountParam || !authManager) return null;
|
|
166
248
|
const contextToken = getRequestTokens()?.accessToken;
|
|
@@ -1098,6 +1180,13 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
|
|
|
1098
1180
|
let failedCount = 0;
|
|
1099
1181
|
const allowedScopes = parseAllowedScopes(allowedScopesValue);
|
|
1100
1182
|
const disabledByAllowedScopes = [];
|
|
1183
|
+
const deniedTools = collectDeniedToolPolicies({
|
|
1184
|
+
readOnly,
|
|
1185
|
+
orgMode,
|
|
1186
|
+
enabledToolsRegex,
|
|
1187
|
+
allowedScopesValue,
|
|
1188
|
+
httpMode
|
|
1189
|
+
});
|
|
1101
1190
|
for (const tool of allEndpoints) {
|
|
1102
1191
|
const endpointConfig = endpointsData.find((e) => e.toolName === tool.alias);
|
|
1103
1192
|
if (!orgMode && endpointConfig && !endpointConfig.scopes && endpointConfig.workScopes) {
|
|
@@ -1256,6 +1345,7 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
|
|
|
1256
1345
|
logger.info(
|
|
1257
1346
|
`Tool registration complete: ${registeredCount} registered, ${skippedCount} skipped, ${failedCount} failed`
|
|
1258
1347
|
);
|
|
1348
|
+
installDeniedToolAuditHandler(server, deniedTools);
|
|
1259
1349
|
return registeredCount;
|
|
1260
1350
|
}
|
|
1261
1351
|
function buildToolsRegistry(readOnly, orgMode, enabledToolsRegex, allowedScopesValue, disabledByAllowedScopes = []) {
|
|
@@ -1372,6 +1462,13 @@ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode =
|
|
|
1372
1462
|
}
|
|
1373
1463
|
}
|
|
1374
1464
|
const disabledByAllowedScopes = [];
|
|
1465
|
+
const deniedTools = collectDeniedToolPolicies({
|
|
1466
|
+
readOnly,
|
|
1467
|
+
orgMode,
|
|
1468
|
+
enabledToolsRegex,
|
|
1469
|
+
allowedScopesValue,
|
|
1470
|
+
httpMode
|
|
1471
|
+
});
|
|
1375
1472
|
const toolsRegistry = buildToolsRegistry(
|
|
1376
1473
|
readOnly,
|
|
1377
1474
|
orgMode,
|
|
@@ -1547,6 +1644,10 @@ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode =
|
|
|
1547
1644
|
if (utility) {
|
|
1548
1645
|
return executeUtilityTool(utility, utilityCtx, parameters);
|
|
1549
1646
|
}
|
|
1647
|
+
const deniedPolicy = deniedTools.get(tool_name);
|
|
1648
|
+
if (deniedPolicy) {
|
|
1649
|
+
auditToolDenied(deniedPolicy, parameters);
|
|
1650
|
+
}
|
|
1550
1651
|
return {
|
|
1551
1652
|
content: [
|
|
1552
1653
|
{
|
|
@@ -1561,6 +1662,7 @@ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode =
|
|
|
1561
1662
|
};
|
|
1562
1663
|
}
|
|
1563
1664
|
);
|
|
1665
|
+
installDeniedToolAuditHandler(server, deniedTools);
|
|
1564
1666
|
}
|
|
1565
1667
|
export {
|
|
1566
1668
|
UTILITY_TOOLS,
|
package/dist/mcp-instructions.js
CHANGED
|
@@ -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
|
|
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."
|
|
10
10
|
];
|
|
11
11
|
if (opts.readOnly) parts.push("This server is read-only; write operations are disabled.");
|
|
12
12
|
if (opts.multiAccount)
|
package/docs/deployment.md
CHANGED
|
@@ -213,7 +213,7 @@ The client automatically discovers OAuth endpoints and opens a browser for authe
|
|
|
213
213
|
- **Tool filtering**: use `--enabled-tools <regex>` or `--preset <names>` to restrict available tools
|
|
214
214
|
- **CORS**: configure `MS365_MCP_CORS_ORIGIN` to restrict allowed origins (defaults to `http://localhost:3000`); set explicitly when clients run on a different origin
|
|
215
215
|
- **Disable Dynamic Client Registration**: when only a known client talks to the server, set `MS365_MCP_DISABLE_DCR=true` (or pass `--no-dynamic-registration`) to close the anonymous `/register` endpoint
|
|
216
|
-
- **Structured audit log**: enabled by default. Every tool invocation emits one JSON line on stderr (captured by the container platform's log collector) and to `~/.ms-365-mcp-server/logs/audit.log` (mode `0o600`) with `{ event, request_id, user_principal_name, tool, http_method, status, duration_ms, target_resource?, error_type?, error_code? }`. When an audited generated Microsoft Graph tool targets a derivable resource through an ID-like path parameter such as `{message-id}` or `{driveItem-id}`, `target_resource` is `{ type, id }`, where `id` is the Graph path up to that resource ID. Later path parameters such as `{path}`, query values, tool parameters, returned content, and Graph response bodies are NEVER recorded, and error messages are reduced to `error_type` / `error_code` so upstream library errors do not leak token fragments or query-string PII. Forms the "who accessed what, when" trail required for GDPR / HIPAA / PIPEDA / SOC 2 audit. Opt-out: `MS365_MCP_AUDIT_LOG=false`
|
|
216
|
+
- **Structured audit log**: enabled by default. Every tool invocation emits one JSON line on stderr (captured by the container platform's log collector) and to `~/.ms-365-mcp-server/logs/audit.log` (mode `0o600`) with `{ event, request_id, user_principal_name, tool, http_method, status, duration_ms, target_resource?, error_type?, error_code? }`. Policy-blocked tool attempts emit `event: "tool.denied"` with `status: "denied"`, `reason` (`allowed_scopes` or `tool_allowlist`), and `missing_scopes` when applicable. When an audited generated Microsoft Graph tool targets a derivable resource through an ID-like path parameter such as `{message-id}` or `{driveItem-id}`, `target_resource` is `{ type, id }`, where `id` is the Graph path up to that resource ID. Later path parameters such as `{path}`, query values, tool parameters, returned content, and Graph response bodies are NEVER recorded, and error messages are reduced to `error_type` / `error_code` so upstream library errors do not leak token fragments or query-string PII. Forms the "who accessed what, when" trail required for GDPR / HIPAA / PIPEDA / SOC 2 audit. Opt-out: `MS365_MCP_AUDIT_LOG=false`
|
|
217
217
|
- **Graph resilience**: every call to Microsoft Graph is wrapped with a fetch timeout (default 100 s via `MS365_MCP_GRAPH_TIMEOUT_MS`), retry-with-backoff on 429 / 503 / 504 / network errors (default 3 retries, full-jitter exponential backoff, honours `Retry-After`; 503 / 504 / network errors only retried for idempotent methods, 429 retried on all methods), and a process-wide circuit breaker that opens after 5 consecutive failures and cools down for 30 s (`MS365_MCP_GRAPH_CIRCUIT_THRESHOLD` / `MS365_MCP_GRAPH_CIRCUIT_COOLDOWN_MS`). Disable the breaker for trusted automation: `MS365_MCP_GRAPH_CIRCUIT_DISABLED=true`
|
|
218
218
|
- **Confirm gate on destructive tools**: opt-in, **off by default**. Enable with `MS365_MCP_REQUIRE_CONFIRM=true`. When on, destructive tools (POST except `readOnly`, PATCH, PUT, DELETE — `delete-mail-message`, `send-mail`, `update-event`, etc.) return `{ "error": "confirmation_required" }` until the caller re-invokes them with `"confirm": true`. Mitigates accidental writes when an LLM misroutes a request or follows an injected instruction. Shipped opt-in so it is a non-breaking, additive layer that can coexist with client-side elicitation prompts (MCP Elicitation API) where the client supports them.
|
|
219
219
|
|
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.145.1",
|
|
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
|
@@ -212,7 +212,7 @@
|
|
|
212
212
|
"toolName": "add-mail-attachment",
|
|
213
213
|
"presets": ["mail", "outlook", "personal"],
|
|
214
214
|
"scopes": ["Mail.ReadWrite"],
|
|
215
|
-
"llmTip": "
|
|
215
|
+
"llmTip": "The only path for attachments under 3MB. contentBytes must carry the complete base64 verbatim; a truncated argument fails with 400 UnableToDeserializePostBody. At 3MB and above use create-mail-attachment-upload-session, which rejects anything smaller. Body requires @odata.type: {\"@odata.type\": \"#microsoft.graph.fileAttachment\", \"name\": \"file.pdf\", \"contentBytes\": \"<base64>\"}."
|
|
216
216
|
},
|
|
217
217
|
{
|
|
218
218
|
"pathPattern": "/me/messages/{message-id}/attachments/createUploadSession",
|
|
@@ -220,7 +220,7 @@
|
|
|
220
220
|
"toolName": "create-mail-attachment-upload-session",
|
|
221
221
|
"presets": ["mail", "outlook", "personal"],
|
|
222
222
|
"scopes": ["Mail.ReadWrite"],
|
|
223
|
-
"llmTip": "For
|
|
223
|
+
"llmTip": "For attachments 3MB to 150MB. Graph rejects smaller files with ErrorAttachmentSizeShouldNotBeLessThanMinimumSize, so under 3MB use add-mail-attachment instead. Body: { AttachmentItem: { attachmentType: 'file', name: 'report.pdf', size: 5000000 } }. Returns a pre-authenticated uploadUrl; the caller PUTs the bytes there itself in ranges up to 4MB. This server does not perform the PUT."
|
|
224
224
|
},
|
|
225
225
|
{
|
|
226
226
|
"pathPattern": "/me/messages/{message-id}/attachments",
|
|
@@ -607,7 +607,7 @@
|
|
|
607
607
|
"toolName": "upload-file-content",
|
|
608
608
|
"presets": ["files", "onedrive", "personal"],
|
|
609
609
|
"scopes": ["Files.ReadWrite"],
|
|
610
|
-
"llmTip": "Body is a base64-encoded string of the file bytes; the server decodes it before PUT.
|
|
610
|
+
"llmTip": "Body is a base64-encoded string of the file bytes; the server decodes it before PUT. Graph accepts up to 250MB here, but the whole string travels as a tool argument and a truncated one decodes to a truncated file with no error, so use create-upload-session rather than emitting a large base64 string. For new files use path format: /items/root:/path/to/file.txt:/content. Overwrites existing files without warning."
|
|
611
611
|
},
|
|
612
612
|
{
|
|
613
613
|
"pathPattern": "/drives/{drive-id}/items/{driveItem-id}/createUploadSession",
|
|
@@ -615,7 +615,7 @@
|
|
|
615
615
|
"toolName": "create-upload-session",
|
|
616
616
|
"presets": ["files", "onedrive", "personal"],
|
|
617
617
|
"scopes": ["Files.ReadWrite"],
|
|
618
|
-
"llmTip": "For large file uploads (no size limit). Returns a pre-authenticated uploadUrl
|
|
618
|
+
"llmTip": "For large file uploads (no size limit, and no minimum unlike the Outlook attachment session). Returns a pre-authenticated uploadUrl; the caller PUTs the bytes there itself. This server does not perform the PUT. For new files use path: /items/{parentId}:/{fileName}:/createUploadSession. Body (optional): { item: { '@microsoft.graph.conflictBehavior': 'rename' } }."
|
|
619
619
|
},
|
|
620
620
|
{
|
|
621
621
|
"pathPattern": "/drives/{drive-id}/items/{driveItem-id}",
|
|
@@ -2609,7 +2609,7 @@
|
|
|
2609
2609
|
"presets": ["personal"],
|
|
2610
2610
|
"scopes": ["User.ReadWrite"],
|
|
2611
2611
|
"contentType": "image/jpeg",
|
|
2612
|
-
"llmTip": "Uploads a new profile photo for the signed-in user. Body is a base64-encoded string of the image bytes (the server decodes before PUT). Photo must be JPEG, max 4 MB. Microsoft 365 generates HD downsized variants automatically (48x48, 64x64, 96x96, 120x120, 240x240, 360x360, 432x432, 504x504, 648x648). For work or school accounts, ProfilePhoto.ReadWrite.All is the more granular alternative permission. Use download-bytes with target=/me/photo/$value to retrieve the current photo."
|
|
2612
|
+
"llmTip": "Uploads a new profile photo for the signed-in user. Body is a base64-encoded string of the image bytes (the server decodes before PUT). Photo must be JPEG, max 4 MB; the base64 travels as a tool argument and a truncated one is written without error, so resize before encoding instead of emitting a large string. Microsoft 365 generates HD downsized variants automatically (48x48, 64x64, 96x96, 120x120, 240x240, 360x360, 432x432, 504x504, 648x648). For work or school accounts, ProfilePhoto.ReadWrite.All is the more granular alternative permission. Use download-bytes with target=/me/photo/$value to retrieve the current photo."
|
|
2613
2613
|
},
|
|
2614
2614
|
{
|
|
2615
2615
|
"pathPattern": "/me/todo/lists",
|