@softeria/ms-365-mcp-server 0.95.0 → 0.97.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.
@@ -51,6 +51,13 @@ export function generateMcpTools(openApiSpec, outputDir) {
51
51
  // Replace with: path: `/...range(param=':value')...`,
52
52
  clientCode = clientCode.replace(/(path:\s*)'(\/[^']*\([^)]*=':[\w]+'\)[^']*)'/g, '$1`$2`');
53
53
 
54
+ // openapi-zod-client emits z.instanceof(File) for `format: binary` bodies; MCP
55
+ // transports JSON so no caller produces File. Body marshaller decodes the string.
56
+ clientCode = clientCode.replace(
57
+ /z\.instanceof\(File\)/g,
58
+ "z.string().describe('Base64-encoded file content. The server decodes it and PUTs the raw bytes to Microsoft Graph.')"
59
+ );
60
+
54
61
  fs.writeFileSync(clientFilePath, clientCode);
55
62
 
56
63
  return true;
@@ -8,24 +8,34 @@ export function createAndSaveSimplifiedOpenAPI(endpointsFile, openapiFile, opena
8
8
  const spec = fs.readFileSync(openapiFile, 'utf8');
9
9
  const openApiSpec = yaml.load(spec);
10
10
 
11
+ // Synthesize paths that the Graph REST API supports but are missing from
12
+ // Microsoft's published OpenAPI metadata (e.g. range(address='{address}')/format
13
+ // — documented in Excel API but not in the OpenAPI spec).
11
14
  for (const endpoint of endpoints) {
12
15
  if (!openApiSpec.paths[endpoint.pathPattern]) {
13
- throw new Error(`Path "${endpoint.pathPattern}" not found in OpenAPI spec.`);
16
+ openApiSpec.paths[endpoint.pathPattern] = {};
14
17
  }
15
18
  }
16
19
 
17
- // Synthesize operations that the Graph REST API supports but are missing from
18
- // Microsoft's published OpenAPI metadata (e.g. PATCH on range(address='{address}')
19
- // for cell-value writes — documented in Excel API but not in the OpenAPI spec).
20
+ // Synthesize operations on existing paths when the method is missing.
20
21
  for (const endpoint of endpoints) {
21
22
  const pathSpec = openApiSpec.paths[endpoint.pathPattern];
22
23
  const methodLower = endpoint.method.toLowerCase();
23
24
  if (pathSpec && !pathSpec[methodLower]) {
25
+ const pathParamMatches = [...endpoint.pathPattern.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
26
+ const synthesizedParameters = pathParamMatches.map((paramName) => ({
27
+ name: paramName,
28
+ in: 'path',
29
+ required: true,
30
+ description: `Path parameter: ${paramName}`,
31
+ schema: { type: 'string' },
32
+ }));
24
33
  pathSpec[methodLower] = {
25
34
  tags: ['drives.driveItem'],
26
35
  summary: endpoint.llmTip || `${endpoint.toolName} (synthesized)`,
27
36
  description: endpoint.llmTip || `${endpoint.toolName} (synthesized)`,
28
37
  operationId: endpoint.toolName,
38
+ parameters: synthesizedParameters,
29
39
  requestBody:
30
40
  methodLower === 'get' || methodLower === 'delete'
31
41
  ? undefined
@@ -441,4 +441,206 @@ describe("graph-tools", () => {
441
441
  expect(prefer === void 0 || !prefer.includes("outlook.body-content-type")).toBe(true);
442
442
  });
443
443
  });
444
+ describe("binary upload bodies", () => {
445
+ it("decodes base64 body to bytes and sets octet-stream Content-Type", async () => {
446
+ const endpoint = makeEndpoint({
447
+ alias: "upload-file-content",
448
+ method: "put",
449
+ path: "/drives/:driveId/items/:driveItemId/content",
450
+ requestFormat: "binary",
451
+ parameters: [
452
+ { name: "driveId", type: "Path", schema: z.string() },
453
+ { name: "driveItemId", type: "Path", schema: z.string() },
454
+ {
455
+ name: "body",
456
+ type: "Body",
457
+ schema: z.string().describe("Base64-encoded file content")
458
+ }
459
+ ]
460
+ });
461
+ const config = makeConfig({
462
+ toolName: "upload-file-content",
463
+ method: "put",
464
+ pathPattern: "/drives/{drive-id}/items/{driveItem-id}/content",
465
+ scopes: ["Files.ReadWrite"]
466
+ });
467
+ mockEndpoints.push(endpoint);
468
+ mockEndpointsJson = [config];
469
+ const graphClient = createMockGraphClient([{ content: [{ type: "text", text: "{}" }] }]);
470
+ const server = createMockServer();
471
+ const { registerGraphTools } = await loadModule();
472
+ registerGraphTools(server, graphClient);
473
+ const original = "Hello, world!";
474
+ const base64 = Buffer.from(original, "utf-8").toString("base64");
475
+ await server.tools.get("upload-file-content").handler({
476
+ driveId: "drive123",
477
+ driveItemId: "item456",
478
+ body: base64
479
+ });
480
+ const [path, options] = graphClient.graphRequest.mock.calls[0];
481
+ expect(path).toBe("/drives/drive123/items/item456/content");
482
+ expect(options.headers["Content-Type"]).toBe("application/octet-stream");
483
+ expect(Buffer.isBuffer(options.body) || options.body instanceof Uint8Array).toBe(true);
484
+ expect(Buffer.from(options.body).toString("utf-8")).toBe(original);
485
+ });
486
+ it("honors endpoints.json contentType override on binary uploads", async () => {
487
+ const endpoint = makeEndpoint({
488
+ alias: "upload-file-content",
489
+ method: "put",
490
+ path: "/drives/:driveId/items/:driveItemId/content",
491
+ requestFormat: "binary",
492
+ parameters: [
493
+ { name: "driveId", type: "Path", schema: z.string() },
494
+ { name: "driveItemId", type: "Path", schema: z.string() },
495
+ { name: "body", type: "Body", schema: z.string() }
496
+ ]
497
+ });
498
+ const config = makeConfig({
499
+ toolName: "upload-file-content",
500
+ method: "put",
501
+ pathPattern: "/drives/{drive-id}/items/{driveItem-id}/content",
502
+ scopes: ["Files.ReadWrite"],
503
+ contentType: "application/pdf"
504
+ });
505
+ mockEndpoints.push(endpoint);
506
+ mockEndpointsJson = [config];
507
+ const graphClient = createMockGraphClient([{ content: [{ type: "text", text: "{}" }] }]);
508
+ const server = createMockServer();
509
+ const { registerGraphTools } = await loadModule();
510
+ registerGraphTools(server, graphClient);
511
+ await server.tools.get("upload-file-content").handler({
512
+ driveId: "d",
513
+ driveItemId: "i",
514
+ body: Buffer.from("%PDF-1.4").toString("base64")
515
+ });
516
+ const [, options] = graphClient.graphRequest.mock.calls[0];
517
+ expect(options.headers["Content-Type"]).toBe("application/pdf");
518
+ });
519
+ });
520
+ describe("download-bytes", () => {
521
+ it("routes a relative Graph path through graphRequest", async () => {
522
+ mockEndpoints.length = 0;
523
+ mockEndpointsJson = [];
524
+ const graphClient = {
525
+ graphRequest: vi.fn().mockResolvedValue({
526
+ content: [
527
+ {
528
+ type: "text",
529
+ text: JSON.stringify({
530
+ contentType: "image/jpeg",
531
+ encoding: "base64",
532
+ contentBytes: "aGk="
533
+ })
534
+ }
535
+ ]
536
+ })
537
+ };
538
+ const server = createMockServer();
539
+ const { registerGraphTools } = await loadModule();
540
+ registerGraphTools(server, graphClient);
541
+ const tool = server.tools.get("download-bytes");
542
+ expect(tool).toBeDefined();
543
+ await tool.handler({ target: "/me/photo/$value" });
544
+ expect(graphClient.graphRequest).toHaveBeenCalledTimes(1);
545
+ const [path, options] = graphClient.graphRequest.mock.calls[0];
546
+ expect(path).toBe("/me/photo/$value");
547
+ expect(options.accessToken).toBeUndefined();
548
+ });
549
+ it("rejects absolute URLs (Graph paths only)", async () => {
550
+ mockEndpoints.length = 0;
551
+ mockEndpointsJson = [];
552
+ const server = createMockServer();
553
+ const { registerGraphTools } = await loadModule();
554
+ registerGraphTools(server, {});
555
+ const tool = server.tools.get("download-bytes");
556
+ const result = await tool.handler({
557
+ target: "https://example.sharepoint.com/d/abc?temp=signed"
558
+ });
559
+ expect(result.isError).toBe(true);
560
+ const payload = JSON.parse(result.content[0].text);
561
+ expect(payload.error).toMatch(/relative Microsoft Graph path/);
562
+ });
563
+ it("rejects targets that do not start with /", async () => {
564
+ mockEndpoints.length = 0;
565
+ mockEndpointsJson = [];
566
+ const server = createMockServer();
567
+ const { registerGraphTools } = await loadModule();
568
+ registerGraphTools(server, {});
569
+ const tool = server.tools.get("download-bytes");
570
+ const result = await tool.handler({ target: "ftp://example.com/x" });
571
+ expect(result.isError).toBe(true);
572
+ const payload = JSON.parse(result.content[0].text);
573
+ expect(payload.error).toMatch(/relative Microsoft Graph path/);
574
+ });
575
+ });
576
+ describe("discovery mode: utility tools", () => {
577
+ it('search-tools surfaces download-bytes for "download" queries', async () => {
578
+ mockEndpoints.length = 0;
579
+ mockEndpointsJson = [];
580
+ const server = createMockServer();
581
+ const { registerDiscoveryTools } = await loadModule();
582
+ registerDiscoveryTools(server, {});
583
+ const result = await server.tools.get("search-tools").handler({ query: "download" });
584
+ const payload = JSON.parse(result.content[0].text);
585
+ const names = payload.tools.map((t) => t.name);
586
+ expect(names).toContain("download-bytes");
587
+ });
588
+ it("get-tool-schema returns the download-bytes parameter schema", async () => {
589
+ mockEndpoints.length = 0;
590
+ mockEndpointsJson = [];
591
+ const server = createMockServer();
592
+ const { registerDiscoveryTools } = await loadModule();
593
+ registerDiscoveryTools(server, {});
594
+ const result = await server.tools.get("get-tool-schema").handler({ tool_name: "download-bytes" });
595
+ const schema = JSON.parse(result.content[0].text);
596
+ expect(schema.name).toBe("download-bytes");
597
+ expect(schema.path).toBe("tool:download-bytes");
598
+ const targetParam = schema.parameters.find((p) => p.name === "target");
599
+ expect(targetParam).toBeDefined();
600
+ expect(targetParam.required).toBe(true);
601
+ });
602
+ it("execute-tool dispatches to download-bytes for a Graph path", async () => {
603
+ mockEndpoints.length = 0;
604
+ mockEndpointsJson = [];
605
+ const graphClient = {
606
+ graphRequest: vi.fn().mockResolvedValue({
607
+ content: [
608
+ {
609
+ type: "text",
610
+ text: JSON.stringify({
611
+ contentType: "image/png",
612
+ encoding: "base64",
613
+ contentBytes: "iVBORw0K"
614
+ })
615
+ }
616
+ ]
617
+ })
618
+ };
619
+ const server = createMockServer();
620
+ const { registerDiscoveryTools } = await loadModule();
621
+ registerDiscoveryTools(server, graphClient);
622
+ const result = await server.tools.get("execute-tool").handler({
623
+ tool_name: "download-bytes",
624
+ parameters: { target: "/me/photo/$value" }
625
+ });
626
+ expect(result.isError).toBeFalsy();
627
+ expect(graphClient.graphRequest).toHaveBeenCalledTimes(1);
628
+ const [path] = graphClient.graphRequest.mock.calls[0];
629
+ expect(path).toBe("/me/photo/$value");
630
+ });
631
+ it("execute-tool reports unknown tool when name matches neither registry", async () => {
632
+ mockEndpoints.length = 0;
633
+ mockEndpointsJson = [];
634
+ const server = createMockServer();
635
+ const { registerDiscoveryTools } = await loadModule();
636
+ registerDiscoveryTools(server, {});
637
+ const result = await server.tools.get("execute-tool").handler({
638
+ tool_name: "no-such-tool",
639
+ parameters: {}
640
+ });
641
+ expect(result.isError).toBe(true);
642
+ const payload = JSON.parse(result.content[0].text);
643
+ expect(payload.error).toMatch(/not found/i);
644
+ });
645
+ });
444
646
  });
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "pathPattern": "/$batch",
4
+ "method": "post",
5
+ "toolName": "graph-batch",
6
+ "scopes": [],
7
+ "llmTip": "Combine up to 20 Graph requests into a single HTTP call. Body: { requests: [{ id: '1', method: 'GET'|'POST'|'PATCH'|'DELETE', url: '/me/messages?$top=5', headers?: {...}, body?: {...}, dependsOn?: ['1'] }, ...] }. Returns { responses: [{ id, status, body, headers }] } in arbitrary order — match by id. Use cases: (1) parallelize many small reads (e.g. fetch 15 mail messages by id in one round-trip); (2) sequence dependent writes via dependsOn; (3) batch many Excel range writes into one call to dramatically reduce latency on large workbook builds. Note: each sub-request URL is relative to the Graph version root (/me/..., /drives/..., NOT https://graph.microsoft.com/v1.0/...)."
8
+ },
2
9
  {
3
10
  "pathPattern": "/me/messages",
4
11
  "method": "get",
@@ -144,13 +151,8 @@
144
151
  "pathPattern": "/me/messages/{message-id}/attachments",
145
152
  "method": "get",
146
153
  "toolName": "list-mail-attachments",
147
- "scopes": ["Mail.Read"]
148
- },
149
- {
150
- "pathPattern": "/me/messages/{message-id}/attachments/{attachment-id}",
151
- "method": "get",
152
- "toolName": "get-mail-attachment",
153
- "scopes": ["Mail.Read"]
154
+ "scopes": ["Mail.Read"],
155
+ "llmTip": "Lists attachments on a message: id, name, contentType, size, isInline. To download the bytes, call download-bytes with target=/me/messages/{message-id}/attachments/{attachment-id}/$value (the /$value suffix returns raw bytes; the bare attachment URL embeds contentBytes in JSON which can truncate large files)."
154
156
  },
155
157
  {
156
158
  "pathPattern": "/me/messages/{message-id}/attachments/{attachment-id}",
@@ -472,14 +474,6 @@
472
474
  "toolName": "list-folder-files",
473
475
  "scopes": ["Files.Read"]
474
476
  },
475
- {
476
- "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/content",
477
- "method": "get",
478
- "toolName": "download-onedrive-file-content",
479
- "scopes": ["Files.Read"],
480
- "returnDownloadUrl": true,
481
- "llmTip": "Returns a temporary download URL, NOT the file content directly."
482
- },
483
477
  {
484
478
  "pathPattern": "/drives/{drive-id}/items/{driveItem-id}",
485
479
  "method": "delete",
@@ -491,7 +485,7 @@
491
485
  "method": "put",
492
486
  "toolName": "upload-file-content",
493
487
  "scopes": ["Files.ReadWrite"],
494
- "llmTip": "Max 4MB. For new files use path format: /items/root:/path/to/file.txt:/content. Overwrites existing files without warning."
488
+ "llmTip": "Body is a base64-encoded string of the file bytes; the server decodes it before PUT. Max 4MB inline (use create-upload-session above 4MB). For new files use path format: /items/root:/path/to/file.txt:/content. Overwrites existing files without warning."
495
489
  },
496
490
  {
497
491
  "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/createUploadSession",
@@ -505,7 +499,7 @@
505
499
  "method": "get",
506
500
  "toolName": "get-drive-item",
507
501
  "scopes": ["Files.Read"],
508
- "llmTip": "Gets metadata for a file or folder: name, size, lastModifiedDateTime, createdBy, webUrl, file (mimeType, hashes), folder (childCount), parentReference. Does not return content use download-onedrive-file-content for that."
502
+ "llmTip": "Gets metadata for a file or folder: name, size, lastModifiedDateTime, createdBy, webUrl, file (mimeType, hashes), folder (childCount), parentReference, and @microsoft.graph.downloadUrl. For the bytes, call download-bytes with target=/drives/{drive-id}/items/{driveItem-id}/content (Graph redirects to the same downloadUrl)."
509
503
  },
510
504
  {
511
505
  "pathPattern": "/drives/{drive-id}/items/{driveItem-id}",
@@ -593,11 +587,13 @@
593
587
  "scopes": ["Files.ReadWrite"]
594
588
  },
595
589
  {
596
- "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets/{workbookWorksheet-id}/range()/format",
590
+ "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets/{workbookWorksheet-id}/range(address='{address}')/format",
597
591
  "method": "patch",
598
592
  "toolName": "format-excel-range",
599
593
  "isExcelOp": true,
600
- "scopes": ["Files.ReadWrite"]
594
+ "scopes": ["Files.ReadWrite"],
595
+ "skipEncoding": ["address"],
596
+ "llmTip": "Apply font/fill/borders/alignment/wrapText/columnWidth/rowHeight to a specific range. Required path param 'address' (e.g. 'A1:E5' or 'Sheet1!A1:E5'). Body: { font: {bold,color,size,italic,name,underline}, fill: {color}, borders: [{sideIndex,style,color,weight}], horizontalAlignment, verticalAlignment, wrapText, columnWidth, rowHeight }."
601
597
  },
602
598
  {
603
599
  "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets/{workbookWorksheet-id}/range()/sort",
@@ -614,6 +610,14 @@
614
610
  "scopes": ["Files.Read"],
615
611
  "skipEncoding": ["address"]
616
612
  },
613
+ {
614
+ "pathPattern": "/me/messages/{message-id}/$value",
615
+ "method": "get",
616
+ "toolName": "get-mail-message-mime",
617
+ "scopes": ["Mail.Read"],
618
+ "acceptType": "text/plain",
619
+ "llmTip": "Download an email message as raw RFC 5322 MIME content (.eml format). Use this when archiving an email to disk preserving all original headers, body, and inline-encoded attachments. Returns the MIME stream as text. Find the message id with list-mail-messages first."
620
+ },
617
621
  {
618
622
  "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets/{workbookWorksheet-id}/range(address='{address}')",
619
623
  "method": "patch",
@@ -641,6 +645,41 @@
641
645
  "skipEncoding": ["address"],
642
646
  "llmTip": "Delete cells at the given range, shifting remaining content. Body: { shift: 'Up' } or { shift: 'Left' }. Use 'Up' to delete entire rows."
643
647
  },
648
+ {
649
+ "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets/{workbookWorksheet-id}/range(address='{address}')/merge",
650
+ "method": "post",
651
+ "toolName": "merge-excel-range",
652
+ "isExcelOp": true,
653
+ "scopes": ["Files.ReadWrite"],
654
+ "skipEncoding": ["address"],
655
+ "llmTip": "Merge the cells in the given range into a single cell. Body: { across: false } merges the entire range into one cell; { across: true } merges each row separately. Useful for building styled headers, banner rows, and report layouts."
656
+ },
657
+ {
658
+ "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets/{workbookWorksheet-id}/range(address='{address}')/unmerge",
659
+ "method": "post",
660
+ "toolName": "unmerge-excel-range",
661
+ "isExcelOp": true,
662
+ "scopes": ["Files.ReadWrite"],
663
+ "skipEncoding": ["address"],
664
+ "llmTip": "Unmerge any merged cells within the given range back into individual cells. No request body. Inverse of merge-excel-range."
665
+ },
666
+ {
667
+ "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets/{workbookWorksheet-id}/range(address='{address}')/clear",
668
+ "method": "post",
669
+ "toolName": "clear-excel-range",
670
+ "isExcelOp": true,
671
+ "scopes": ["Files.ReadWrite"],
672
+ "skipEncoding": ["address"],
673
+ "llmTip": "Clear cell contents and/or formatting on the given range. Body: { applyTo: 'All' | 'Formats' | 'Contents' }. 'Contents' wipes values but keeps formatting; 'Formats' resets styling but keeps values; 'All' wipes both. Use this to reset a worksheet section before a fresh write rather than overwriting cell-by-cell."
674
+ },
675
+ {
676
+ "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets/{workbookWorksheet-id}/usedRange()",
677
+ "method": "get",
678
+ "toolName": "get-excel-used-range",
679
+ "isExcelOp": true,
680
+ "scopes": ["Files.Read"],
681
+ "llmTip": "Get the smallest range that encompasses any cells with values or formatting on the worksheet. Returns address, values, formulas, numberFormat, rowCount, columnCount. Use this to discover the populated bounds of a sheet before reading or appending — avoids guessing how far data extends. Optional $select to trim the response."
682
+ },
644
683
  {
645
684
  "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/tables/{workbookTable-id}/rows/itemAt(index={index})",
646
685
  "method": "patch",
@@ -1025,22 +1064,6 @@
1025
1064
  "workScopes": ["User.Read.All"],
1026
1065
  "llmTip": "Lists users who report directly to a specific user. Use with get-user-manager to traverse the org hierarchy."
1027
1066
  },
1028
- {
1029
- "pathPattern": "/me/photo/$value",
1030
- "method": "get",
1031
- "toolName": "get-my-profile-photo",
1032
- "scopes": ["User.Read"],
1033
- "returnDownloadUrl": true,
1034
- "llmTip": "Returns the current user's profile photo as binary image data. The photo is typically in JPEG format."
1035
- },
1036
- {
1037
- "pathPattern": "/users/{user-id}/photo/$value",
1038
- "method": "get",
1039
- "toolName": "get-user-profile-photo",
1040
- "workScopes": ["User.Read.All"],
1041
- "returnDownloadUrl": true,
1042
- "llmTip": "Returns a specific user's profile photo as binary image data. Use the user ID or UPN (email)."
1043
- },
1044
1067
  {
1045
1068
  "pathPattern": "/me/people",
1046
1069
  "method": "get",
@@ -1133,15 +1156,7 @@
1133
1156
  "method": "get",
1134
1157
  "toolName": "list-chat-message-hosted-contents",
1135
1158
  "workScopes": ["ChatMessage.Read"],
1136
- "llmTip": "Lists hosted-content references (inline images, code snippets) attached to a Teams chat message. Returns an array of { id, contentType }. Use get-chat-message-hosted-content with the id to download the bytes. Hosted content IDs can also be parsed from the <img src> URL in the message body between /hostedContents/ and /$value."
1137
- },
1138
- {
1139
- "pathPattern": "/chats/{chat-id}/messages/{chatMessage-id}/hostedContents/{chatMessageHostedContent-id}/$value",
1140
- "method": "get",
1141
- "toolName": "get-chat-message-hosted-content",
1142
- "workScopes": ["ChatMessage.Read"],
1143
- "returnDownloadUrl": true,
1144
- "llmTip": "Returns raw bytes of a hosted content item (typically image/png or image/jpeg) attached to a Teams 1:1 or group chat message. Use list-chat-message-hosted-contents to discover IDs, or extract the ID from the <img src> URL in the message body."
1159
+ "llmTip": "Lists hosted-content references (inline images, code snippets) attached to a Teams chat message. Returns { id, contentType } per item. To download bytes, call download-bytes with target=/chats/{chat-id}/messages/{chatMessage-id}/hostedContents/{id}/$value. IDs can also be extracted from the <img src> URL in the message body between /hostedContents/ and /$value."
1145
1160
  },
1146
1161
  {
1147
1162
  "pathPattern": "/chats/{chat-id}/messages",
@@ -1209,15 +1224,7 @@
1209
1224
  "method": "get",
1210
1225
  "toolName": "list-channel-message-hosted-contents",
1211
1226
  "workScopes": ["ChannelMessage.Read.All"],
1212
- "llmTip": "Lists hosted-content references (inline images, code snippets) attached to a Teams channel message. Returns an array of { id, contentType }. Use get-channel-message-hosted-content with the id to download bytes."
1213
- },
1214
- {
1215
- "pathPattern": "/teams/{team-id}/channels/{channel-id}/messages/{chatMessage-id}/hostedContents/{chatMessageHostedContent-id}/$value",
1216
- "method": "get",
1217
- "toolName": "get-channel-message-hosted-content",
1218
- "workScopes": ["ChannelMessage.Read.All"],
1219
- "returnDownloadUrl": true,
1220
- "llmTip": "Returns raw bytes of a hosted content item attached to a Teams channel message. Use list-channel-message-hosted-contents to discover IDs."
1227
+ "llmTip": "Lists hosted-content references (inline images, code snippets) attached to a Teams channel message. Returns { id, contentType } per item. To download bytes, call download-bytes with target=/teams/{team-id}/channels/{channel-id}/messages/{chatMessage-id}/hostedContents/{id}/$value."
1221
1228
  },
1222
1229
  {
1223
1230
  "pathPattern": "/teams/{team-id}/channels/{channel-id}/messages",