@softeria/ms-365-mcp-server 0.142.0 → 0.144.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 +10 -0
- package/dist/__tests__/graph-client.test.js +128 -0
- package/dist/__tests__/graph-tools.test.js +274 -31
- package/dist/auth-tools.js +0 -1
- package/dist/cli.js +18 -0
- package/dist/graph-client.js +119 -37
- package/dist/graph-tools.js +121 -26
- package/dist/lib/message-signoff.js +230 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -619,6 +619,8 @@ Environment variables:
|
|
|
619
619
|
- `MS365_MCP_MAX_ITEMS=<n>`: Maximum number of items accumulated when `fetchAllPages: true` (positive integer, default `10000`). Pagination stops and the response is truncated once this many items are collected.
|
|
620
620
|
- `MS365_MCP_ALLOW_PAGINATION=0|false|no`: Disable multi-page following entirely. When set, the `fetchAllPages` parameter is not advertised on tools, and any request that still passes it returns only the first page (default: pagination enabled).
|
|
621
621
|
- `MS365_MCP_BODY_FORMAT=html`: Return email bodies as HTML instead of plain text (default: text)
|
|
622
|
+
- `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)
|
|
623
|
+
- `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)
|
|
622
624
|
- `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`)
|
|
623
625
|
- `MS365_MCP_TRUST_PROXY_HOPS=<n>`: Number of trusted reverse-proxy hops in HTTP mode (default `1`). Accurate per-IP rate limiting depends on this matching your deployment — set to the number of proxies in front of the server, `0` to use the raw socket peer IP, or a comma-separated subnet list
|
|
624
626
|
- `MS365_MCP_CLOUD_TYPE=global|china`: Microsoft cloud environment (alternative to --cloud flag)
|
|
@@ -774,6 +776,14 @@ The Key Vault integration uses `DefaultAzureCredential` from the Azure Identity
|
|
|
774
776
|
|
|
775
777
|
The Azure Key Vault packages (`@azure/identity` and `@azure/keyvault-secrets`) are optional dependencies. They are only loaded when `MS365_MCP_KEYVAULT_URL` is configured. If you don't use Key Vault, these packages are not required.
|
|
776
778
|
|
|
779
|
+
## Message Signoff
|
|
780
|
+
|
|
781
|
+
Outgoing messages can be wrapped in a configurable signoff (e.g. a `🤖` prefix) so recipients can tell agent-sent messages from ones you typed yourself. Off by default — enable it with `--message-signoff-prefix` / `--message-signoff-suffix` (env: `MS365_MCP_MESSAGE_SIGNOFF_PREFIX` / `MS365_MCP_MESSAGE_SIGNOFF_SUFFIX`); `--no-message-signoff` or an empty env value turns it back off.
|
|
782
|
+
|
|
783
|
+
Once configured, it applies to all Teams messages (sends, replies and edits, including via `graph-batch`), to direct mail sends (`send-mail`, reply/forward, their shared-mailbox variants, and group thread replies), and to mail drafts as their content is written — `send-draft-message` sends a draft as-is, so a draft you wrote yourself goes out untouched. A message that already carries the marker is not signed twice, and a send whose body cannot take the signoff is refused rather than sent unsigned.
|
|
784
|
+
|
|
785
|
+
Markers may contain markup (e.g. a coloured `<span>`) as long as it renders visible text. Note that the signoff is a guardrail against an agent misusing the tools it was given, not a hard security boundary — an agent with shell access on the same machine could simply restart the server without it.
|
|
786
|
+
|
|
777
787
|
## Production Deployment
|
|
778
788
|
|
|
779
789
|
See [docs/deployment.md](docs/deployment.md) for a full guide to hosting the server for organization-wide access, including Docker, Azure Container Apps, Azure App Service, Azure AD app registration, reverse proxy setup, client configuration, and exposed endpoints.
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import GraphClient from "../graph-client.js";
|
|
3
|
+
import { fetchWithResilience } from "../lib/graph-resilience.js";
|
|
4
|
+
vi.mock("../logger.js", () => ({
|
|
5
|
+
default: {
|
|
6
|
+
info: vi.fn(),
|
|
7
|
+
warn: vi.fn(),
|
|
8
|
+
error: vi.fn(),
|
|
9
|
+
debug: vi.fn()
|
|
10
|
+
}
|
|
11
|
+
}));
|
|
12
|
+
vi.mock("../lib/graph-resilience.js", () => ({
|
|
13
|
+
fetchWithResilience: vi.fn(),
|
|
14
|
+
getSharedBreaker: vi.fn(() => ({})),
|
|
15
|
+
loadResilienceConfig: vi.fn(() => ({}))
|
|
16
|
+
}));
|
|
17
|
+
const fetchWithResilienceMock = vi.mocked(fetchWithResilience);
|
|
18
|
+
function createGraphClient() {
|
|
19
|
+
return new GraphClient(
|
|
20
|
+
{
|
|
21
|
+
getToken: vi.fn().mockResolvedValue("token")
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
clientId: "client-id",
|
|
25
|
+
tenantId: "tenant-id",
|
|
26
|
+
cloudType: "global"
|
|
27
|
+
}
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
describe("GraphClient audit metadata", () => {
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
vi.clearAllMocks();
|
|
33
|
+
});
|
|
34
|
+
it("adds HTTP status metadata to successful Graph responses", async () => {
|
|
35
|
+
fetchWithResilienceMock.mockResolvedValue(
|
|
36
|
+
new Response(JSON.stringify({ id: "user-1" }), {
|
|
37
|
+
status: 200,
|
|
38
|
+
headers: { "content-type": "application/json" }
|
|
39
|
+
})
|
|
40
|
+
);
|
|
41
|
+
const result = await createGraphClient().graphRequest("/me");
|
|
42
|
+
expect(result._meta).toMatchObject({ http_status: 200 });
|
|
43
|
+
expect(JSON.parse(result.content[0].text)).toEqual({ id: "user-1" });
|
|
44
|
+
});
|
|
45
|
+
it("preserves HTTP status metadata when response headers are requested", async () => {
|
|
46
|
+
fetchWithResilienceMock.mockResolvedValue(
|
|
47
|
+
new Response(JSON.stringify({ id: "task-1" }), {
|
|
48
|
+
status: 200,
|
|
49
|
+
headers: { "content-type": "application/json", etag: 'W/"etag-1"' }
|
|
50
|
+
})
|
|
51
|
+
);
|
|
52
|
+
const result = await createGraphClient().graphRequest("/me/planner/tasks/task-1", {
|
|
53
|
+
includeHeaders: true
|
|
54
|
+
});
|
|
55
|
+
expect(result._meta).toMatchObject({ http_status: 200 });
|
|
56
|
+
expect(JSON.parse(result.content[0].text)).toMatchObject({
|
|
57
|
+
id: "task-1",
|
|
58
|
+
_etag: 'W/"etag-1"'
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
it("adds HTTP status and Graph error code metadata to failed Graph responses", async () => {
|
|
62
|
+
fetchWithResilienceMock.mockResolvedValue(
|
|
63
|
+
new Response(
|
|
64
|
+
JSON.stringify({
|
|
65
|
+
error: {
|
|
66
|
+
code: "accessDenied",
|
|
67
|
+
message: "Access denied"
|
|
68
|
+
}
|
|
69
|
+
}),
|
|
70
|
+
{
|
|
71
|
+
status: 403,
|
|
72
|
+
statusText: "Forbidden",
|
|
73
|
+
headers: { "content-type": "application/json" }
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
);
|
|
77
|
+
const result = await createGraphClient().graphRequest("/me/drive");
|
|
78
|
+
expect(result.isError).toBe(true);
|
|
79
|
+
expect(result._meta).toMatchObject({
|
|
80
|
+
http_status: 403,
|
|
81
|
+
error_code: "accessDenied"
|
|
82
|
+
});
|
|
83
|
+
expect(JSON.parse(result.content[0].text).error).toContain(
|
|
84
|
+
"Microsoft Graph API error: 403 Forbidden"
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
it("adds aggregate subrequest metadata to successful Graph batch responses", async () => {
|
|
88
|
+
fetchWithResilienceMock.mockResolvedValue(
|
|
89
|
+
new Response(
|
|
90
|
+
JSON.stringify({
|
|
91
|
+
responses: [
|
|
92
|
+
{ id: "1", status: 200, body: { id: "message-1" } },
|
|
93
|
+
{
|
|
94
|
+
id: "2",
|
|
95
|
+
status: 403,
|
|
96
|
+
body: { error: { code: "accessDenied", message: "Access denied" } }
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: "3",
|
|
100
|
+
status: 429,
|
|
101
|
+
body: { error: { code: "tooManyRequests", message: "Slow down" } }
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: "4",
|
|
105
|
+
status: 403,
|
|
106
|
+
body: { error: { code: "accessDenied", message: "Access denied" } }
|
|
107
|
+
}
|
|
108
|
+
]
|
|
109
|
+
}),
|
|
110
|
+
{
|
|
111
|
+
status: 200,
|
|
112
|
+
headers: { "content-type": "application/json" }
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
);
|
|
116
|
+
const result = await createGraphClient().graphRequest("/$batch", {
|
|
117
|
+
method: "POST",
|
|
118
|
+
body: JSON.stringify({ requests: [] })
|
|
119
|
+
});
|
|
120
|
+
expect(result.isError).toBeUndefined();
|
|
121
|
+
expect(result._meta).toMatchObject({
|
|
122
|
+
http_status: 200,
|
|
123
|
+
graph_batch_subrequest_count: 4,
|
|
124
|
+
graph_batch_http_status_counts: { "200": 1, "403": 2, "429": 1 },
|
|
125
|
+
graph_batch_error_code_counts: { accessDenied: 2, tooManyRequests: 1 }
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -3,14 +3,23 @@ import { z } from "zod";
|
|
|
3
3
|
import { tmpdir } from "os";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
6
|
+
const loggerMock = vi.hoisted(() => ({
|
|
7
|
+
info: vi.fn(),
|
|
8
|
+
warn: vi.fn(),
|
|
9
|
+
error: vi.fn(),
|
|
10
|
+
debug: vi.fn()
|
|
11
|
+
}));
|
|
6
12
|
vi.mock("../logger.js", () => ({
|
|
7
|
-
default:
|
|
8
|
-
info: vi.fn(),
|
|
9
|
-
warn: vi.fn(),
|
|
10
|
-
error: vi.fn(),
|
|
11
|
-
debug: vi.fn()
|
|
12
|
-
}
|
|
13
|
+
default: loggerMock
|
|
13
14
|
}));
|
|
15
|
+
const auditLogMock = vi.hoisted(() => vi.fn());
|
|
16
|
+
vi.mock("../audit-log.js", async () => {
|
|
17
|
+
const actual = await vi.importActual("../audit-log.js");
|
|
18
|
+
return {
|
|
19
|
+
...actual,
|
|
20
|
+
auditLog: auditLogMock
|
|
21
|
+
};
|
|
22
|
+
});
|
|
14
23
|
const mockEndpoints = [];
|
|
15
24
|
vi.mock("../generated/client-beta.js", () => ({ api: { endpoints: [] } }));
|
|
16
25
|
vi.mock("../generated/client.js", () => ({
|
|
@@ -89,10 +98,6 @@ async function loadModule() {
|
|
|
89
98
|
const mod = await import("../graph-tools.js");
|
|
90
99
|
return mod;
|
|
91
100
|
}
|
|
92
|
-
async function spyOnAuditLogger() {
|
|
93
|
-
const { __testing } = await import("../audit-log.js");
|
|
94
|
-
return vi.spyOn(__testing.auditLogger, "info").mockImplementation(() => __testing.auditLogger);
|
|
95
|
-
}
|
|
96
101
|
function createMockServer() {
|
|
97
102
|
const tools = /* @__PURE__ */ new Map();
|
|
98
103
|
return {
|
|
@@ -123,6 +128,187 @@ describe("graph-tools", () => {
|
|
|
123
128
|
mockEndpointsJson = [];
|
|
124
129
|
vi.clearAllMocks();
|
|
125
130
|
});
|
|
131
|
+
describe("audit outcome metadata", () => {
|
|
132
|
+
it("includes HTTP status on successful Graph tool calls", async () => {
|
|
133
|
+
const endpoint = makeEndpoint();
|
|
134
|
+
const config = makeConfig();
|
|
135
|
+
mockEndpoints.push(endpoint);
|
|
136
|
+
mockEndpointsJson = [config];
|
|
137
|
+
const graphClient = createMockGraphClient([
|
|
138
|
+
{
|
|
139
|
+
content: [{ type: "text", text: JSON.stringify({ value: [] }) }],
|
|
140
|
+
_meta: { http_status: 200 }
|
|
141
|
+
}
|
|
142
|
+
]);
|
|
143
|
+
const server = createMockServer();
|
|
144
|
+
const { registerGraphTools } = await loadModule();
|
|
145
|
+
registerGraphTools(
|
|
146
|
+
server,
|
|
147
|
+
graphClient
|
|
148
|
+
);
|
|
149
|
+
await server.tools.get("test-tool").handler({});
|
|
150
|
+
expect(auditLogMock).toHaveBeenCalledWith(
|
|
151
|
+
expect.objectContaining({
|
|
152
|
+
event: "tool.call",
|
|
153
|
+
tool: "test-tool",
|
|
154
|
+
status: "success",
|
|
155
|
+
http_status: 200
|
|
156
|
+
})
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
it("includes HTTP status and Graph error code on failed Graph tool calls", async () => {
|
|
160
|
+
const endpoint = makeEndpoint();
|
|
161
|
+
const config = makeConfig();
|
|
162
|
+
mockEndpoints.push(endpoint);
|
|
163
|
+
mockEndpointsJson = [config];
|
|
164
|
+
const graphClient = createMockGraphClient([
|
|
165
|
+
{
|
|
166
|
+
content: [
|
|
167
|
+
{
|
|
168
|
+
type: "text",
|
|
169
|
+
text: JSON.stringify({ error: "Microsoft Graph API error: 403 Forbidden" })
|
|
170
|
+
}
|
|
171
|
+
],
|
|
172
|
+
isError: true,
|
|
173
|
+
_meta: { http_status: 403, error_code: "accessDenied" }
|
|
174
|
+
}
|
|
175
|
+
]);
|
|
176
|
+
const server = createMockServer();
|
|
177
|
+
const { registerGraphTools } = await loadModule();
|
|
178
|
+
registerGraphTools(server, graphClient);
|
|
179
|
+
await server.tools.get("test-tool").handler({});
|
|
180
|
+
expect(auditLogMock).toHaveBeenCalledWith(
|
|
181
|
+
expect.objectContaining({
|
|
182
|
+
event: "tool.call",
|
|
183
|
+
tool: "test-tool",
|
|
184
|
+
status: "error",
|
|
185
|
+
http_status: 403,
|
|
186
|
+
error_code: "accessDenied"
|
|
187
|
+
})
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
it("includes HTTP status on utility tool calls that reach Graph", async () => {
|
|
191
|
+
mockEndpoints.length = 0;
|
|
192
|
+
mockEndpointsJson = [];
|
|
193
|
+
const graphClient = {
|
|
194
|
+
graphRequest: vi.fn().mockResolvedValue({
|
|
195
|
+
content: [
|
|
196
|
+
{
|
|
197
|
+
type: "text",
|
|
198
|
+
text: JSON.stringify({
|
|
199
|
+
contentType: "image/jpeg",
|
|
200
|
+
encoding: "base64",
|
|
201
|
+
contentBytes: "aGk="
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
],
|
|
205
|
+
_meta: { http_status: 200 }
|
|
206
|
+
})
|
|
207
|
+
};
|
|
208
|
+
const server = createMockServer();
|
|
209
|
+
const { registerGraphTools } = await loadModule();
|
|
210
|
+
registerGraphTools(server, graphClient);
|
|
211
|
+
await server.tools.get("download-bytes").handler({ target: "/me/photo/$value" });
|
|
212
|
+
expect(auditLogMock).toHaveBeenCalledWith(
|
|
213
|
+
expect.objectContaining({
|
|
214
|
+
event: "tool.call",
|
|
215
|
+
tool: "download-bytes",
|
|
216
|
+
status: "success",
|
|
217
|
+
http_method: "GET",
|
|
218
|
+
http_status: 200
|
|
219
|
+
})
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
it("includes HTTP status and Graph error code on failed utility Graph calls", async () => {
|
|
223
|
+
mockEndpoints.length = 0;
|
|
224
|
+
mockEndpointsJson = [];
|
|
225
|
+
const graphClient = {
|
|
226
|
+
graphRequest: vi.fn().mockResolvedValue({
|
|
227
|
+
content: [
|
|
228
|
+
{
|
|
229
|
+
type: "text",
|
|
230
|
+
text: JSON.stringify({ error: "Microsoft Graph API error: 403 Forbidden" })
|
|
231
|
+
}
|
|
232
|
+
],
|
|
233
|
+
isError: true,
|
|
234
|
+
_meta: { http_status: 403, error_code: "accessDenied" }
|
|
235
|
+
})
|
|
236
|
+
};
|
|
237
|
+
const server = createMockServer();
|
|
238
|
+
const { registerGraphTools } = await loadModule();
|
|
239
|
+
registerGraphTools(server, graphClient);
|
|
240
|
+
await server.tools.get("download-bytes").handler({ target: "/me/photo/$value" });
|
|
241
|
+
expect(auditLogMock).toHaveBeenCalledWith(
|
|
242
|
+
expect.objectContaining({
|
|
243
|
+
event: "tool.call",
|
|
244
|
+
tool: "download-bytes",
|
|
245
|
+
status: "error",
|
|
246
|
+
http_status: 403,
|
|
247
|
+
error_code: "accessDenied"
|
|
248
|
+
})
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
it("copies Graph batch outcome metadata into audit events", async () => {
|
|
252
|
+
const endpoint = makeEndpoint({
|
|
253
|
+
method: "post",
|
|
254
|
+
path: "/$batch",
|
|
255
|
+
alias: "graph-batch",
|
|
256
|
+
parameters: [{ name: "body", type: "Body", schema: z.object({}).passthrough() }]
|
|
257
|
+
});
|
|
258
|
+
const config = makeConfig({
|
|
259
|
+
pathPattern: "/$batch",
|
|
260
|
+
method: "post",
|
|
261
|
+
toolName: "graph-batch"
|
|
262
|
+
});
|
|
263
|
+
mockEndpoints.push(endpoint);
|
|
264
|
+
mockEndpointsJson = [config];
|
|
265
|
+
const graphClient = createMockGraphClient([
|
|
266
|
+
{
|
|
267
|
+
content: [
|
|
268
|
+
{
|
|
269
|
+
type: "text",
|
|
270
|
+
text: JSON.stringify({
|
|
271
|
+
responses: [
|
|
272
|
+
{ id: "1", status: 200, body: { id: "message-1" } },
|
|
273
|
+
{
|
|
274
|
+
id: "2",
|
|
275
|
+
status: 403,
|
|
276
|
+
body: { error: { code: "accessDenied", message: "Access denied" } }
|
|
277
|
+
}
|
|
278
|
+
]
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
],
|
|
282
|
+
_meta: {
|
|
283
|
+
http_status: 200,
|
|
284
|
+
graph_batch_subrequest_count: 2,
|
|
285
|
+
graph_batch_http_status_counts: { "200": 1, "403": 1 },
|
|
286
|
+
graph_batch_error_code_counts: { accessDenied: 1 }
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
]);
|
|
290
|
+
const server = createMockServer();
|
|
291
|
+
const { registerGraphTools } = await loadModule();
|
|
292
|
+
registerGraphTools(
|
|
293
|
+
server,
|
|
294
|
+
graphClient
|
|
295
|
+
);
|
|
296
|
+
await server.tools.get("graph-batch").handler({
|
|
297
|
+
body: { requests: [{ id: "1", method: "GET", url: "/me" }] }
|
|
298
|
+
});
|
|
299
|
+
expect(auditLogMock).toHaveBeenCalledWith(
|
|
300
|
+
expect.objectContaining({
|
|
301
|
+
event: "tool.call",
|
|
302
|
+
tool: "graph-batch",
|
|
303
|
+
status: "success",
|
|
304
|
+
http_status: 200,
|
|
305
|
+
graph_batch_subrequest_count: 2,
|
|
306
|
+
graph_batch_http_status_counts: { "200": 1, "403": 1 },
|
|
307
|
+
graph_batch_error_code_counts: { accessDenied: 1 }
|
|
308
|
+
})
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
126
312
|
describe("$count advanced query mode", () => {
|
|
127
313
|
it("should set ConsistencyLevel: eventual header when $count=true", async () => {
|
|
128
314
|
const endpoint = makeEndpoint();
|
|
@@ -166,12 +352,11 @@ describe("graph-tools", () => {
|
|
|
166
352
|
const server = createMockServer();
|
|
167
353
|
const { registerGraphTools } = await loadModule();
|
|
168
354
|
registerGraphTools(server, graphClient);
|
|
169
|
-
const auditSpy = await spyOnAuditLogger();
|
|
170
355
|
await server.tools.get("get-drive-item").handler({
|
|
171
356
|
driveId: "drive-1",
|
|
172
357
|
driveItemId: "item-2"
|
|
173
358
|
});
|
|
174
|
-
expect(
|
|
359
|
+
expect(auditLogMock).toHaveBeenCalledWith(
|
|
175
360
|
expect.objectContaining({
|
|
176
361
|
event: "tool.call",
|
|
177
362
|
tool: "get-drive-item",
|
|
@@ -182,7 +367,6 @@ describe("graph-tools", () => {
|
|
|
182
367
|
}
|
|
183
368
|
})
|
|
184
369
|
);
|
|
185
|
-
auditSpy.mockRestore();
|
|
186
370
|
});
|
|
187
371
|
it("adds target_resource to failed generated Graph tool audit events", async () => {
|
|
188
372
|
const endpoint = makeEndpoint({
|
|
@@ -210,13 +394,12 @@ describe("graph-tools", () => {
|
|
|
210
394
|
server,
|
|
211
395
|
graphClient
|
|
212
396
|
);
|
|
213
|
-
const auditSpy = await spyOnAuditLogger();
|
|
214
397
|
const result = await server.tools.get("get-drive-item").handler({
|
|
215
398
|
driveId: "drive-1",
|
|
216
399
|
driveItemId: "item-2"
|
|
217
400
|
});
|
|
218
401
|
expect(result.isError).toBe(true);
|
|
219
|
-
expect(
|
|
402
|
+
expect(auditLogMock).toHaveBeenCalledWith(
|
|
220
403
|
expect.objectContaining({
|
|
221
404
|
event: "tool.call",
|
|
222
405
|
tool: "get-drive-item",
|
|
@@ -228,7 +411,6 @@ describe("graph-tools", () => {
|
|
|
228
411
|
}
|
|
229
412
|
})
|
|
230
413
|
);
|
|
231
|
-
auditSpy.mockRestore();
|
|
232
414
|
});
|
|
233
415
|
it("derives target_resource from generic ID path parameters", async () => {
|
|
234
416
|
const endpoint = makeEndpoint({
|
|
@@ -248,11 +430,10 @@ describe("graph-tools", () => {
|
|
|
248
430
|
const server = createMockServer();
|
|
249
431
|
const { registerGraphTools } = await loadModule();
|
|
250
432
|
registerGraphTools(server, graphClient);
|
|
251
|
-
const auditSpy = await spyOnAuditLogger();
|
|
252
433
|
await server.tools.get("get-mail-message").handler({
|
|
253
434
|
messageId: "message-1"
|
|
254
435
|
});
|
|
255
|
-
expect(
|
|
436
|
+
expect(auditLogMock).toHaveBeenCalledWith(
|
|
256
437
|
expect.objectContaining({
|
|
257
438
|
event: "tool.call",
|
|
258
439
|
tool: "get-mail-message",
|
|
@@ -263,7 +444,6 @@ describe("graph-tools", () => {
|
|
|
263
444
|
}
|
|
264
445
|
})
|
|
265
446
|
);
|
|
266
|
-
auditSpy.mockRestore();
|
|
267
447
|
});
|
|
268
448
|
it("omits target_resource when an ID path parameter is missing", async () => {
|
|
269
449
|
const endpoint = makeEndpoint({
|
|
@@ -287,18 +467,16 @@ describe("graph-tools", () => {
|
|
|
287
467
|
const server = createMockServer();
|
|
288
468
|
const { registerGraphTools } = await loadModule();
|
|
289
469
|
registerGraphTools(server, graphClient);
|
|
290
|
-
const auditSpy = await spyOnAuditLogger();
|
|
291
470
|
await server.tools.get("get-drive-item").handler({
|
|
292
471
|
driveId: "drive-1"
|
|
293
472
|
});
|
|
294
|
-
const [payload] =
|
|
473
|
+
const [payload] = auditLogMock.mock.calls[0];
|
|
295
474
|
expect(payload).toMatchObject({
|
|
296
475
|
event: "tool.call",
|
|
297
476
|
tool: "get-drive-item",
|
|
298
477
|
status: "success"
|
|
299
478
|
});
|
|
300
479
|
expect(payload).not.toHaveProperty("target_resource");
|
|
301
|
-
auditSpy.mockRestore();
|
|
302
480
|
});
|
|
303
481
|
it("omits SharePoint path parameters from target_resource", async () => {
|
|
304
482
|
const endpoint = makeEndpoint({
|
|
@@ -322,12 +500,11 @@ describe("graph-tools", () => {
|
|
|
322
500
|
const server = createMockServer();
|
|
323
501
|
const { registerGraphTools } = await loadModule();
|
|
324
502
|
registerGraphTools(server, graphClient);
|
|
325
|
-
const auditSpy = await spyOnAuditLogger();
|
|
326
503
|
await server.tools.get("get-sharepoint-site-by-path").handler({
|
|
327
504
|
siteId: "contoso.sharepoint.com",
|
|
328
505
|
path: "/sites/Finance"
|
|
329
506
|
});
|
|
330
|
-
expect(
|
|
507
|
+
expect(auditLogMock).toHaveBeenCalledWith(
|
|
331
508
|
expect.objectContaining({
|
|
332
509
|
event: "tool.call",
|
|
333
510
|
tool: "get-sharepoint-site-by-path",
|
|
@@ -338,9 +515,8 @@ describe("graph-tools", () => {
|
|
|
338
515
|
}
|
|
339
516
|
})
|
|
340
517
|
);
|
|
341
|
-
const [payload] =
|
|
518
|
+
const [payload] = auditLogMock.mock.calls[0];
|
|
342
519
|
expect(JSON.stringify(payload)).not.toContain("Finance");
|
|
343
|
-
auditSpy.mockRestore();
|
|
344
520
|
});
|
|
345
521
|
it("omits target_resource for generated broad list/search audit events", async () => {
|
|
346
522
|
const endpoint = makeEndpoint({
|
|
@@ -359,16 +535,14 @@ describe("graph-tools", () => {
|
|
|
359
535
|
const server = createMockServer();
|
|
360
536
|
const { registerGraphTools } = await loadModule();
|
|
361
537
|
registerGraphTools(server, graphClient);
|
|
362
|
-
const auditSpy = await spyOnAuditLogger();
|
|
363
538
|
await server.tools.get("list-mail-messages").handler({ search: "budget" });
|
|
364
|
-
const [payload] =
|
|
539
|
+
const [payload] = auditLogMock.mock.calls[0];
|
|
365
540
|
expect(payload).toMatchObject({
|
|
366
541
|
event: "tool.call",
|
|
367
542
|
tool: "list-mail-messages",
|
|
368
543
|
status: "success"
|
|
369
544
|
});
|
|
370
545
|
expect(payload).not.toHaveProperty("target_resource");
|
|
371
|
-
auditSpy.mockRestore();
|
|
372
546
|
});
|
|
373
547
|
});
|
|
374
548
|
describe("fetchAllPages pagination", () => {
|
|
@@ -411,6 +585,55 @@ describe("graph-tools", () => {
|
|
|
411
585
|
expect(parsed.value.map((v) => v.id)).toEqual(["1", "2", "3"]);
|
|
412
586
|
expect(parsed["@odata.nextLink"]).toBeUndefined();
|
|
413
587
|
});
|
|
588
|
+
it("returns and audits a later-page Graph error instead of partial success", async () => {
|
|
589
|
+
const endpoint = makeEndpoint();
|
|
590
|
+
const config = makeConfig();
|
|
591
|
+
mockEndpoints.push(endpoint);
|
|
592
|
+
mockEndpointsJson = [config];
|
|
593
|
+
const graphClient = createMockGraphClient([
|
|
594
|
+
{
|
|
595
|
+
content: [
|
|
596
|
+
{
|
|
597
|
+
type: "text",
|
|
598
|
+
text: JSON.stringify({
|
|
599
|
+
value: [{ id: "1" }],
|
|
600
|
+
"@odata.nextLink": "https://graph.microsoft.com/v1.0/me/messages?$skip=1"
|
|
601
|
+
})
|
|
602
|
+
}
|
|
603
|
+
],
|
|
604
|
+
_meta: { http_status: 200 }
|
|
605
|
+
},
|
|
606
|
+
{
|
|
607
|
+
content: [
|
|
608
|
+
{
|
|
609
|
+
type: "text",
|
|
610
|
+
text: JSON.stringify({ error: "Microsoft Graph API error: 429 Too Many Requests" })
|
|
611
|
+
}
|
|
612
|
+
],
|
|
613
|
+
isError: true,
|
|
614
|
+
_meta: { http_status: 429, error_code: "tooManyRequests" }
|
|
615
|
+
}
|
|
616
|
+
]);
|
|
617
|
+
const server = createMockServer();
|
|
618
|
+
const { registerGraphTools } = await loadModule();
|
|
619
|
+
registerGraphTools(server, graphClient);
|
|
620
|
+
const result = await server.tools.get("test-tool").handler({ fetchAllPages: true });
|
|
621
|
+
expect(result.isError).toBe(true);
|
|
622
|
+
expect(JSON.parse(result.content[0].text).error).toContain("429 Too Many Requests");
|
|
623
|
+
expect(graphClient.graphRequest).toHaveBeenCalledTimes(2);
|
|
624
|
+
expect(loggerMock.error).not.toHaveBeenCalledWith(
|
|
625
|
+
expect.stringContaining("Error during pagination")
|
|
626
|
+
);
|
|
627
|
+
expect(auditLogMock).toHaveBeenCalledWith(
|
|
628
|
+
expect.objectContaining({
|
|
629
|
+
event: "tool.call",
|
|
630
|
+
tool: "test-tool",
|
|
631
|
+
status: "error",
|
|
632
|
+
http_status: 429,
|
|
633
|
+
error_code: "tooManyRequests"
|
|
634
|
+
})
|
|
635
|
+
);
|
|
636
|
+
});
|
|
414
637
|
it("merges all pages under --toon and encodes the combined result once (#560)", async () => {
|
|
415
638
|
const endpoint = makeEndpoint();
|
|
416
639
|
const config = makeConfig();
|
|
@@ -1021,7 +1244,8 @@ describe("graph-tools", () => {
|
|
|
1021
1244
|
const graphClient = {
|
|
1022
1245
|
downloadToFile: vi.fn().mockResolvedValue({
|
|
1023
1246
|
contentType: "image/jpeg",
|
|
1024
|
-
contentLength: 2
|
|
1247
|
+
contentLength: 2,
|
|
1248
|
+
httpStatus: 200
|
|
1025
1249
|
})
|
|
1026
1250
|
};
|
|
1027
1251
|
const server = createMockServer();
|
|
@@ -1039,6 +1263,15 @@ describe("graph-tools", () => {
|
|
|
1039
1263
|
expect(result.isError).toBeUndefined();
|
|
1040
1264
|
const payload = JSON.parse(result.content[0].text);
|
|
1041
1265
|
expect(payload).toEqual({ path: outputPath, contentType: "image/jpeg", bytesWritten: 2 });
|
|
1266
|
+
expect(result._meta).toMatchObject({ http_status: 200 });
|
|
1267
|
+
expect(auditLogMock).toHaveBeenCalledWith(
|
|
1268
|
+
expect.objectContaining({
|
|
1269
|
+
event: "tool.call",
|
|
1270
|
+
tool: "download-bytes-to-file",
|
|
1271
|
+
status: "success",
|
|
1272
|
+
http_status: 200
|
|
1273
|
+
})
|
|
1274
|
+
);
|
|
1042
1275
|
});
|
|
1043
1276
|
it("rejects a relative outputPath", async () => {
|
|
1044
1277
|
mockEndpoints.length = 0;
|
|
@@ -1178,7 +1411,8 @@ describe("graph-tools", () => {
|
|
|
1178
1411
|
"@microsoft.graph.downloadUrl": downloadUrl
|
|
1179
1412
|
})
|
|
1180
1413
|
}
|
|
1181
|
-
]
|
|
1414
|
+
],
|
|
1415
|
+
_meta: { http_status: 200 }
|
|
1182
1416
|
})
|
|
1183
1417
|
};
|
|
1184
1418
|
const server = createMockServer();
|
|
@@ -1196,6 +1430,15 @@ describe("graph-tools", () => {
|
|
|
1196
1430
|
expect(payload.name).toBe("report.pdf");
|
|
1197
1431
|
expect(payload.size).toBe(12727);
|
|
1198
1432
|
expect(payload.contentType).toBe("application/pdf");
|
|
1433
|
+
expect(result._meta).toMatchObject({ http_status: 200 });
|
|
1434
|
+
expect(auditLogMock).toHaveBeenCalledWith(
|
|
1435
|
+
expect.objectContaining({
|
|
1436
|
+
event: "tool.call",
|
|
1437
|
+
tool: "get-download-url",
|
|
1438
|
+
status: "success",
|
|
1439
|
+
http_status: 200
|
|
1440
|
+
})
|
|
1441
|
+
);
|
|
1199
1442
|
});
|
|
1200
1443
|
it("forces a JSON body on the metadata request so it works under --toon (#560)", async () => {
|
|
1201
1444
|
mockEndpoints.length = 0;
|
package/dist/auth-tools.js
CHANGED
package/dist/cli.js
CHANGED
|
@@ -3,6 +3,7 @@ import { readFileSync } from "fs";
|
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
5
|
import { getCombinedPresetPattern, listPresets, presetRequiresOrgMode } from "./tool-categories.js";
|
|
6
|
+
import { assertSignoffMarkersVisible } from "./lib/message-signoff.js";
|
|
6
7
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
8
|
const packageJsonPath = path.join(__dirname, "..", "package.json");
|
|
8
9
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
@@ -15,6 +16,12 @@ program.name("ms-365-mcp-server").description("Microsoft 365 MCP Server").versio
|
|
|
15
16
|
"--expected-home-account-id <id>",
|
|
16
17
|
"Require local MSAL authentication to use this exact MSAL homeAccountId"
|
|
17
18
|
).option("--read-only", "Start server in read-only mode, disabling write operations").option(
|
|
19
|
+
"--message-signoff-prefix <text>",
|
|
20
|
+
"Signoff prepended to outgoing messages (Teams sends, replies and edits; mail sends and drafts) so recipients can tell they were agent-sent, e.g. \u{1F916} (default: none). Equivalent env var: MS365_MCP_MESSAGE_SIGNOFF_PREFIX."
|
|
21
|
+
).option(
|
|
22
|
+
"--message-signoff-suffix <text>",
|
|
23
|
+
"Signoff appended to outgoing messages (default: none). Equivalent env var: MS365_MCP_MESSAGE_SIGNOFF_SUFFIX."
|
|
24
|
+
).option("--no-message-signoff", "Disable both message signoffs, overriding the env vars.").option(
|
|
18
25
|
"--http [address]",
|
|
19
26
|
'Use Streamable HTTP transport instead of stdio. Format: [host:]port (e.g., "localhost:3000", ":3000", "3000"). Default: all interfaces on port 3000'
|
|
20
27
|
).option(
|
|
@@ -65,6 +72,17 @@ program.name("ms-365-mcp-server").description("Microsoft 365 MCP Server").versio
|
|
|
65
72
|
function parseArgs() {
|
|
66
73
|
program.parse();
|
|
67
74
|
const options = program.opts();
|
|
75
|
+
if (typeof options.messageSignoffSuffix === "string") {
|
|
76
|
+
process.env.MS365_MCP_MESSAGE_SIGNOFF_SUFFIX = options.messageSignoffSuffix;
|
|
77
|
+
}
|
|
78
|
+
if (typeof options.messageSignoffPrefix === "string") {
|
|
79
|
+
process.env.MS365_MCP_MESSAGE_SIGNOFF_PREFIX = options.messageSignoffPrefix;
|
|
80
|
+
}
|
|
81
|
+
if (options.messageSignoff === false) {
|
|
82
|
+
process.env.MS365_MCP_MESSAGE_SIGNOFF_SUFFIX = "";
|
|
83
|
+
process.env.MS365_MCP_MESSAGE_SIGNOFF_PREFIX = "";
|
|
84
|
+
}
|
|
85
|
+
assertSignoffMarkersVisible();
|
|
68
86
|
if (options.listPresets) {
|
|
69
87
|
const presets = listPresets();
|
|
70
88
|
console.log(JSON.stringify({ presets }, null, 2));
|