@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 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(auditSpy).toHaveBeenCalledWith(
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(auditSpy).toHaveBeenCalledWith(
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(auditSpy).toHaveBeenCalledWith(
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] = auditSpy.mock.calls[0];
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(auditSpy).toHaveBeenCalledWith(
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] = auditSpy.mock.calls[0];
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] = auditSpy.mock.calls[0];
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;
@@ -34,7 +34,6 @@ function registerAuthTools(server, authManager) {
34
34
  type: "text",
35
35
  text: JSON.stringify({
36
36
  status: "Login successful",
37
- message: "Browser authentication completed successfully.",
38
37
  ...loginResult
39
38
  })
40
39
  }
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));