@access-mcp/announcements 0.3.0 → 0.5.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.
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi, Mock } from "vitest";
2
2
  import { AnnouncementsServer } from "./server.js";
3
- import { DrupalAuthProvider } from "@access-mcp/shared";
3
+ import { DrupalAuthProvider, requestContextStorage, RequestContext } from "@access-mcp/shared";
4
4
 
5
5
  // Mock the DrupalAuthProvider
6
6
  vi.mock("@access-mcp/shared", async () => {
@@ -10,6 +10,8 @@ vi.mock("@access-mcp/shared", async () => {
10
10
  DrupalAuthProvider: vi.fn().mockImplementation(() => ({
11
11
  ensureAuthenticated: vi.fn().mockResolvedValue(undefined),
12
12
  getUserUuid: vi.fn().mockReturnValue("user-uuid-123"),
13
+ setActingUser: vi.fn(),
14
+ getActingUser: vi.fn(),
13
15
  get: vi.fn(),
14
16
  post: vi.fn(),
15
17
  patch: vi.fn(),
@@ -33,12 +35,12 @@ describe("AnnouncementsServer", () => {
33
35
 
34
36
  beforeEach(() => {
35
37
  server = new AnnouncementsServer();
36
-
38
+
37
39
  // Mock the httpClient
38
40
  mockHttpClient = {
39
41
  get: vi.fn(),
40
42
  };
41
-
43
+
42
44
  // Override the httpClient getter
43
45
  Object.defineProperty(server, "httpClient", {
44
46
  get: () => mockHttpClient,
@@ -59,6 +61,7 @@ describe("AnnouncementsServer", () => {
59
61
  author: "ACCESS Support",
60
62
  tags: ["maintenance", "scheduled"],
61
63
  affinity_group: ["123", "456"],
64
+ url: "https://support.access-ci.org/announcements/scheduled-maintenance",
62
65
  },
63
66
  {
64
67
  title: "New GPU Nodes Available",
@@ -67,6 +70,7 @@ describe("AnnouncementsServer", () => {
67
70
  author: "Resource Team",
68
71
  tags: ["gpu", "hardware"],
69
72
  affinity_group: ["789"],
73
+ url: "https://support.access-ci.org/announcements/new-gpu-nodes-available",
70
74
  },
71
75
  ],
72
76
  };
@@ -193,6 +197,7 @@ describe("AnnouncementsServer", () => {
193
197
  author: "Support",
194
198
  tags: ["gpu", "maintenance"],
195
199
  affinity_group: [],
200
+ url: "https://support.access-ci.org/announcements/gpu-maintenance",
196
201
  },
197
202
  ],
198
203
  };
@@ -230,6 +235,7 @@ describe("AnnouncementsServer", () => {
230
235
  published_date: "2024-03-14",
231
236
  tags: ["ai"],
232
237
  affinity_group: [],
238
+ url: "https://support.access-ci.org/announcements/update-1",
233
239
  },
234
240
  ],
235
241
  };
@@ -266,6 +272,7 @@ describe("AnnouncementsServer", () => {
266
272
  author: "Admin",
267
273
  tags: ["urgent"],
268
274
  affinity_group: [],
275
+ url: "https://support.access-ci.org/announcements/todays-update",
269
276
  },
270
277
  ],
271
278
  };
@@ -365,6 +372,7 @@ describe("AnnouncementsServer", () => {
365
372
  published_date: "2024-03-15",
366
373
  tags: ["tag1", "tag2", "tag3"],
367
374
  affinity_group: [],
375
+ url: "https://support.access-ci.org/announcements/test",
368
376
  },
369
377
  ],
370
378
  };
@@ -392,6 +400,7 @@ describe("AnnouncementsServer", () => {
392
400
  published_date: "2024-03-15",
393
401
  tags: "gpu, machine-learning, hpc",
394
402
  affinity_group: [],
403
+ url: "https://support.access-ci.org/announcements/test",
395
404
  },
396
405
  ],
397
406
  };
@@ -419,6 +428,7 @@ describe("AnnouncementsServer", () => {
419
428
  published_date: "2024-03-15",
420
429
  tags: "",
421
430
  affinity_group: [],
431
+ url: "https://support.access-ci.org/announcements/test",
422
432
  },
423
433
  ],
424
434
  };
@@ -441,10 +451,28 @@ describe("AnnouncementsServer", () => {
441
451
  const mockResponse = {
442
452
  status: 200,
443
453
  data: [
444
- { title: "1", published_date: "2024-03-15", tags: ["gpu", "maintenance"], affinity_group: [] },
445
- { title: "2", published_date: "2024-03-14", tags: ["gpu", "network"], affinity_group: [] },
446
- { title: "3", published_date: "2024-03-13", tags: ["gpu", "storage"], affinity_group: [] },
447
- { title: "4", published_date: "2024-03-12", tags: ["maintenance"], affinity_group: [] },
454
+ {
455
+ title: "1",
456
+ published_date: "2024-03-15",
457
+ tags: ["gpu", "maintenance"],
458
+ affinity_group: [],
459
+ url: "https://support.access-ci.org/announcements/1",
460
+ },
461
+ {
462
+ title: "2",
463
+ published_date: "2024-03-14",
464
+ tags: ["gpu", "network"],
465
+ affinity_group: [],
466
+ url: "https://support.access-ci.org/announcements/2",
467
+ },
468
+ {
469
+ title: "3",
470
+ published_date: "2024-03-13",
471
+ tags: ["gpu", "storage"],
472
+ affinity_group: [],
473
+ url: "https://support.access-ci.org/announcements/3",
474
+ },
475
+ { title: "4", published_date: "2024-03-12", tags: ["maintenance"], affinity_group: [], url: "https://support.access-ci.org/announcements/4" },
448
476
  ],
449
477
  };
450
478
 
@@ -472,6 +500,7 @@ describe("AnnouncementsServer", () => {
472
500
  published_date: "2024-03-15",
473
501
  tags: [],
474
502
  affinity_group: [],
503
+ url: "https://support.access-ci.org/announcements/test",
475
504
  },
476
505
  ],
477
506
  };
@@ -495,6 +524,8 @@ describe("AnnouncementsServer", () => {
495
524
  let mockDrupalAuth: {
496
525
  ensureAuthenticated: Mock;
497
526
  getUserUuid: Mock;
527
+ setActingUser: Mock;
528
+ getActingUser: Mock;
498
529
  get: Mock;
499
530
  post: Mock;
500
531
  patch: Mock;
@@ -506,12 +537,14 @@ describe("AnnouncementsServer", () => {
506
537
  process.env.DRUPAL_API_URL = "https://test.drupal.site";
507
538
  process.env.DRUPAL_USERNAME = "test_user";
508
539
  process.env.DRUPAL_PASSWORD = "test_password";
509
- process.env.ACTING_USER_UID = "1985";
540
+ process.env.ACTING_USER = "testuser@access-ci.org";
510
541
 
511
542
  // Create a fresh mock for each test
512
543
  mockDrupalAuth = {
513
544
  ensureAuthenticated: vi.fn().mockResolvedValue(undefined),
514
545
  getUserUuid: vi.fn().mockReturnValue("user-uuid-123"),
546
+ setActingUser: vi.fn(),
547
+ getActingUser: vi.fn(),
515
548
  get: vi.fn(),
516
549
  post: vi.fn(),
517
550
  patch: vi.fn(),
@@ -526,16 +559,11 @@ describe("AnnouncementsServer", () => {
526
559
  delete process.env.DRUPAL_API_URL;
527
560
  delete process.env.DRUPAL_USERNAME;
528
561
  delete process.env.DRUPAL_PASSWORD;
529
- delete process.env.ACTING_USER_UID;
562
+ delete process.env.ACTING_USER;
530
563
  });
531
564
 
532
565
  describe("create_announcement", () => {
533
566
  it("should create an announcement with required fields", async () => {
534
- // Mock user UUID lookup for ACTING_USER_UID
535
- mockDrupalAuth.get.mockResolvedValueOnce({
536
- data: [{ id: "acting-user-uuid-123" }],
537
- });
538
-
539
567
  mockDrupalAuth.post.mockResolvedValue({
540
568
  data: {
541
569
  id: "new-announcement-uuid",
@@ -553,15 +581,11 @@ describe("AnnouncementsServer", () => {
553
581
  arguments: {
554
582
  title: "Test Announcement",
555
583
  body: "<p>This is a test</p>",
584
+ summary: "Test summary",
556
585
  },
557
586
  },
558
587
  });
559
588
 
560
- // Should have looked up the acting user UUID
561
- expect(mockDrupalAuth.get).toHaveBeenCalledWith(
562
- "/jsonapi/user/user?filter[drupal_internal__uid]=1985"
563
- );
564
-
565
589
  expect(mockDrupalAuth.post).toHaveBeenCalledWith(
566
590
  "/jsonapi/node/access_news",
567
591
  expect.objectContaining({
@@ -573,16 +597,9 @@ describe("AnnouncementsServer", () => {
573
597
  body: expect.objectContaining({
574
598
  value: "<p>This is a test</p>",
575
599
  format: "basic_html",
600
+ summary: "Test summary",
576
601
  }),
577
602
  }),
578
- relationships: expect.objectContaining({
579
- uid: {
580
- data: {
581
- type: "user--user",
582
- id: "acting-user-uuid-123",
583
- },
584
- },
585
- }),
586
603
  }),
587
604
  })
588
605
  );
@@ -593,16 +610,14 @@ describe("AnnouncementsServer", () => {
593
610
  });
594
611
 
595
612
  it("should look up tags by name when provided (with caching)", async () => {
596
- // Mock user lookup first, then bulk tag fetch for cache
597
- mockDrupalAuth.get
598
- .mockResolvedValueOnce({ data: [{ id: "acting-user-uuid" }] }) // user lookup
599
- .mockResolvedValueOnce({
600
- data: [
601
- { id: "tag-uuid-1", attributes: { name: "gpu" } },
602
- { id: "tag-uuid-2", attributes: { name: "maintenance" } },
603
- { id: "tag-uuid-3", attributes: { name: "hpc" } },
604
- ]
605
- }); // bulk tag cache fetch
613
+ mockDrupalAuth.get.mockResolvedValueOnce({
614
+ data: [
615
+ { id: "tag-uuid-1", attributes: { name: "gpu" } },
616
+ { id: "tag-uuid-2", attributes: { name: "maintenance" } },
617
+ { id: "tag-uuid-3", attributes: { name: "hpc" } },
618
+ ],
619
+ links: {},
620
+ });
606
621
 
607
622
  mockDrupalAuth.post.mockResolvedValue({
608
623
  data: {
@@ -618,20 +633,16 @@ describe("AnnouncementsServer", () => {
618
633
  arguments: {
619
634
  title: "Test",
620
635
  body: "Body",
636
+ summary: "Summary",
621
637
  tags: ["gpu", "maintenance"],
622
638
  },
623
639
  },
624
640
  });
625
641
 
626
- // Should have looked up user, then fetched all tags for cache
627
- expect(mockDrupalAuth.get).toHaveBeenCalledWith(
628
- "/jsonapi/user/user?filter[drupal_internal__uid]=1985"
629
- );
630
642
  expect(mockDrupalAuth.get).toHaveBeenCalledWith(
631
- "/jsonapi/taxonomy_term/tags?page[limit]=500"
643
+ "/jsonapi/taxonomy_term/tags?page[limit]=50"
632
644
  );
633
645
 
634
- // Should have created with the correct tag UUIDs
635
646
  expect(mockDrupalAuth.post).toHaveBeenCalledWith(
636
647
  "/jsonapi/node/access_news",
637
648
  expect.objectContaining({
@@ -667,30 +678,118 @@ describe("AnnouncementsServer", () => {
667
678
  expect(responseData.error).toContain("DRUPAL_API_URL");
668
679
  });
669
680
 
670
- it("should fail without ACTING_USER_UID", async () => {
671
- delete process.env.ACTING_USER_UID;
681
+ it("should fail without ACTING_USER", async () => {
682
+ delete process.env.ACTING_USER;
672
683
 
673
684
  const result = await server["handleToolCall"]({
674
685
  method: "tools/call",
675
686
  params: {
676
- name: "create_announcement",
677
- arguments: {
687
+ name: "get_my_announcements",
688
+ arguments: {},
689
+ },
690
+ });
691
+
692
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
693
+ expect(responseData.error).toContain("No acting user specified");
694
+ });
695
+
696
+ it("should use actingUser from request context when env var not set", async () => {
697
+ delete process.env.ACTING_USER;
698
+
699
+ mockDrupalAuth.post.mockResolvedValue({
700
+ data: {
701
+ id: "new-announcement-uuid",
702
+ attributes: {
678
703
  title: "Test",
679
- body: "Body",
704
+ drupal_internal__nid: 12345,
680
705
  },
681
706
  },
682
707
  });
683
708
 
709
+ const context: RequestContext = {
710
+ actingUser: "contextuser@access-ci.org",
711
+ };
712
+
713
+ const result = await requestContextStorage.run(context, async () => {
714
+ return server["handleToolCall"]({
715
+ method: "tools/call",
716
+ params: {
717
+ name: "create_announcement",
718
+ arguments: {
719
+ title: "Test",
720
+ body: "Body",
721
+ summary: "Summary",
722
+ },
723
+ },
724
+ });
725
+ });
726
+
684
727
  const responseData = JSON.parse((result.content[0] as TextContent).text);
685
- expect(responseData.error).toContain("ACTING_USER_UID");
686
- expect(responseData.error).toContain("No acting user specified");
728
+ expect(responseData.success).toBe(true);
687
729
  });
688
730
 
689
- it("should create announcement with external link", async () => {
690
- mockDrupalAuth.get.mockResolvedValueOnce({
691
- data: [{ id: "acting-user-uuid-123" }],
731
+ it("should prefer request context actingUser over env var", async () => {
732
+ process.env.ACTING_USER = "envuser@access-ci.org";
733
+
734
+ mockDrupalAuth.post.mockResolvedValue({
735
+ data: {
736
+ id: "new-announcement-uuid",
737
+ attributes: { title: "Test" },
738
+ },
692
739
  });
693
740
 
741
+ const context: RequestContext = {
742
+ actingUser: "contextuser@access-ci.org",
743
+ };
744
+
745
+ await requestContextStorage.run(context, async () => {
746
+ return server["handleToolCall"]({
747
+ method: "tools/call",
748
+ params: {
749
+ name: "create_announcement",
750
+ arguments: {
751
+ title: "Test",
752
+ body: "Body",
753
+ summary: "Summary",
754
+ },
755
+ },
756
+ });
757
+ });
758
+
759
+ // Should have called setActingUser with the context value, not env var
760
+ expect(mockDrupalAuth.setActingUser).toHaveBeenCalledWith("contextuser@access-ci.org");
761
+ });
762
+
763
+ it("should set actingUser on DrupalAuth from request context", async () => {
764
+ const context: RequestContext = {
765
+ actingUser: "researcher@access-ci.org",
766
+ };
767
+
768
+ mockDrupalAuth.post.mockResolvedValue({
769
+ data: {
770
+ id: "new-announcement-uuid",
771
+ attributes: { title: "Test" },
772
+ },
773
+ });
774
+
775
+ await requestContextStorage.run(context, async () => {
776
+ return server["handleToolCall"]({
777
+ method: "tools/call",
778
+ params: {
779
+ name: "create_announcement",
780
+ arguments: {
781
+ title: "Test",
782
+ body: "Body",
783
+ summary: "Summary",
784
+ },
785
+ },
786
+ });
787
+ });
788
+
789
+ expect(mockDrupalAuth.setActingUser).toHaveBeenCalledWith("researcher@access-ci.org");
790
+ });
791
+
792
+ it("should create announcement with external link", async () => {
694
793
  mockDrupalAuth.post.mockResolvedValue({
695
794
  data: {
696
795
  id: "new-announcement-uuid",
@@ -733,10 +832,6 @@ describe("AnnouncementsServer", () => {
733
832
  });
734
833
 
735
834
  it("should create announcement with where_to_share", async () => {
736
- mockDrupalAuth.get.mockResolvedValueOnce({
737
- data: [{ id: "acting-user-uuid-123" }],
738
- });
739
-
740
835
  mockDrupalAuth.post.mockResolvedValue({
741
836
  data: {
742
837
  id: "new-announcement-uuid",
@@ -776,10 +871,6 @@ describe("AnnouncementsServer", () => {
776
871
  });
777
872
 
778
873
  it("should fail with invalid where_to_share value", async () => {
779
- mockDrupalAuth.get.mockResolvedValueOnce({
780
- data: [{ id: "acting-user-uuid-123" }],
781
- });
782
-
783
874
  const result = await server["handleToolCall"]({
784
875
  method: "tools/call",
785
876
  params: {
@@ -798,14 +889,8 @@ describe("AnnouncementsServer", () => {
798
889
  });
799
890
 
800
891
  it("should create announcement with affinity group", async () => {
801
- // User lookup
802
- mockDrupalAuth.get.mockResolvedValueOnce({
803
- data: [{ id: "acting-user-uuid-123" }],
804
- });
805
- // Affinity group lookup by title
806
- mockDrupalAuth.get.mockResolvedValueOnce({
807
- data: [],
808
- });
892
+ // Affinity group lookup by title (first by field_group_id returns empty, then by title)
893
+ mockDrupalAuth.get.mockResolvedValueOnce({ data: [] });
809
894
  mockDrupalAuth.get.mockResolvedValueOnce({
810
895
  data: [{ id: "group-uuid-456", attributes: { title: "Test Group" } }],
811
896
  });
@@ -851,9 +936,6 @@ describe("AnnouncementsServer", () => {
851
936
  });
852
937
 
853
938
  it("should fail when affinity group not found", async () => {
854
- mockDrupalAuth.get.mockResolvedValueOnce({
855
- data: [{ id: "acting-user-uuid-123" }],
856
- });
857
939
  // Both lookups return empty
858
940
  mockDrupalAuth.get.mockResolvedValueOnce({ data: [] });
859
941
  mockDrupalAuth.get.mockResolvedValueOnce({ data: [] });
@@ -1049,23 +1131,20 @@ describe("AnnouncementsServer", () => {
1049
1131
  });
1050
1132
 
1051
1133
  describe("get_my_announcements", () => {
1052
- it("should fetch announcements for acting user", async () => {
1053
- // First call: user UUID lookup, Second call: announcements
1054
- mockDrupalAuth.get
1055
- .mockResolvedValueOnce({ data: [{ id: "acting-user-uuid-456" }] })
1056
- .mockResolvedValueOnce({
1057
- data: [
1058
- {
1059
- id: "announcement-1",
1060
- attributes: {
1061
- title: "My First Announcement",
1062
- status: false,
1063
- created: "2024-03-15T10:00:00Z",
1064
- body: { value: "<p>Content</p>", summary: "Summary" },
1065
- },
1134
+ it("should fetch announcements via views endpoint without user UUID lookup", async () => {
1135
+ mockDrupalAuth.get.mockResolvedValueOnce({
1136
+ data: [
1137
+ {
1138
+ id: "announcement-1",
1139
+ attributes: {
1140
+ title: "My First Announcement",
1141
+ status: false,
1142
+ created: "2024-03-15T10:00:00Z",
1143
+ body: { value: "<p>Content</p>", summary: "Summary" },
1066
1144
  },
1067
- ],
1068
- });
1145
+ },
1146
+ ],
1147
+ });
1069
1148
 
1070
1149
  const result = await server["handleToolCall"]({
1071
1150
  method: "tools/call",
@@ -1075,13 +1154,10 @@ describe("AnnouncementsServer", () => {
1075
1154
  },
1076
1155
  });
1077
1156
 
1078
- // Should look up acting user UUID first
1079
- expect(mockDrupalAuth.get).toHaveBeenCalledWith(
1080
- "/jsonapi/user/user?filter[drupal_internal__uid]=1985"
1081
- );
1082
- // Then fetch announcements for that user
1157
+ // Should make exactly 1 call — no user UUID lookup
1158
+ expect(mockDrupalAuth.get).toHaveBeenCalledTimes(1);
1083
1159
  expect(mockDrupalAuth.get).toHaveBeenCalledWith(
1084
- expect.stringContaining("filter[uid.id]=acting-user-uuid-456")
1160
+ "/jsonapi/views/mcp_my_announcements/page_1?page[limit]=10"
1085
1161
  );
1086
1162
 
1087
1163
  const responseData = JSON.parse((result.content[0] as TextContent).text);
@@ -1089,22 +1165,120 @@ describe("AnnouncementsServer", () => {
1089
1165
  expect(responseData.items[0].title).toBe("My First Announcement");
1090
1166
  expect(responseData.items[0].status).toBe("draft");
1091
1167
  });
1092
- });
1093
1168
 
1094
- describe("get_announcement_context", () => {
1095
- it("should return tags and affinity groups for coordinator", async () => {
1096
- // First call: get user UUID
1097
- mockDrupalAuth.get.mockResolvedValueOnce({
1098
- data: [{ id: "user-uuid-123", attributes: { name: "testuser" } }],
1169
+ it("should use default limit of 25", async () => {
1170
+ mockDrupalAuth.get.mockResolvedValueOnce({ data: [] });
1171
+
1172
+ await server["handleToolCall"]({
1173
+ method: "tools/call",
1174
+ params: {
1175
+ name: "get_my_announcements",
1176
+ arguments: {},
1177
+ },
1099
1178
  });
1100
- // Second call: get tags (parallel)
1179
+
1180
+ expect(mockDrupalAuth.get).toHaveBeenCalledWith(
1181
+ "/jsonapi/views/mcp_my_announcements/page_1?page[limit]=25"
1182
+ );
1183
+ });
1184
+
1185
+ it("should map published status and build edit_url from nid", async () => {
1101
1186
  mockDrupalAuth.get.mockResolvedValueOnce({
1102
1187
  data: [
1103
- { id: "tag-1", attributes: { name: "gpu" } },
1104
- { id: "tag-2", attributes: { name: "hpc" } },
1188
+ {
1189
+ id: "ann-published",
1190
+ attributes: {
1191
+ title: "Published One",
1192
+ status: true,
1193
+ drupal_internal__nid: 999,
1194
+ created: "2024-03-15T10:00:00Z",
1195
+ field_published_date: "2024-03-15",
1196
+ body: { value: "<p>Body</p>", summary: "Short summary" },
1197
+ },
1198
+ },
1199
+ {
1200
+ id: "ann-draft",
1201
+ attributes: {
1202
+ title: "Draft One",
1203
+ status: false,
1204
+ drupal_internal__nid: 1000,
1205
+ created: "2024-03-14T10:00:00Z",
1206
+ body: { value: "<p>Draft body</p>" },
1207
+ },
1208
+ },
1105
1209
  ],
1106
1210
  });
1107
- // Third call: get affinity groups (parallel)
1211
+
1212
+ const result = await server["handleToolCall"]({
1213
+ method: "tools/call",
1214
+ params: {
1215
+ name: "get_my_announcements",
1216
+ arguments: {},
1217
+ },
1218
+ });
1219
+
1220
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1221
+ expect(responseData.total).toBe(2);
1222
+
1223
+ // Published announcement
1224
+ expect(responseData.items[0].uuid).toBe("ann-published");
1225
+ expect(responseData.items[0].status).toBe("published");
1226
+ expect(responseData.items[0].nid).toBe(999);
1227
+ expect(responseData.items[0].edit_url).toBe("https://test.drupal.site/node/999/edit");
1228
+ expect(responseData.items[0].published_date).toBe("2024-03-15");
1229
+ expect(responseData.items[0].summary).toBe("Short summary");
1230
+
1231
+ // Draft announcement — summary falls back to body text
1232
+ expect(responseData.items[1].uuid).toBe("ann-draft");
1233
+ expect(responseData.items[1].status).toBe("draft");
1234
+ expect(responseData.items[1].edit_url).toBe("https://test.drupal.site/node/1000/edit");
1235
+ expect(responseData.items[1].summary).toContain("Draft body");
1236
+ });
1237
+
1238
+ it("should handle empty results from views endpoint", async () => {
1239
+ mockDrupalAuth.get.mockResolvedValueOnce({ data: [] });
1240
+
1241
+ const result = await server["handleToolCall"]({
1242
+ method: "tools/call",
1243
+ params: {
1244
+ name: "get_my_announcements",
1245
+ arguments: {},
1246
+ },
1247
+ });
1248
+
1249
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1250
+ expect(responseData.total).toBe(0);
1251
+ expect(responseData.items).toEqual([]);
1252
+ });
1253
+
1254
+ it("should use acting user from request context", async () => {
1255
+ delete process.env.ACTING_USER;
1256
+
1257
+ mockDrupalAuth.get.mockResolvedValueOnce({ data: [] });
1258
+
1259
+ const context: RequestContext = {
1260
+ actingUser: "contextuser@access-ci.org",
1261
+ };
1262
+
1263
+ const result = await requestContextStorage.run(context, async () => {
1264
+ return server["handleToolCall"]({
1265
+ method: "tools/call",
1266
+ params: {
1267
+ name: "get_my_announcements",
1268
+ arguments: {},
1269
+ },
1270
+ });
1271
+ });
1272
+
1273
+ // Should succeed — acting user comes from request context
1274
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1275
+ expect(responseData.total).toBe(0);
1276
+ expect(mockDrupalAuth.get).toHaveBeenCalledTimes(1);
1277
+ });
1278
+ });
1279
+
1280
+ describe("get_announcement_context", () => {
1281
+ it("should fetch affinity groups without tags", async () => {
1108
1282
  mockDrupalAuth.get.mockResolvedValueOnce({
1109
1283
  data: [
1110
1284
  {
@@ -1112,8 +1286,8 @@ describe("AnnouncementsServer", () => {
1112
1286
  attributes: {
1113
1287
  title: "Test Group",
1114
1288
  field_group_id: 123,
1115
- field_affinity_group_category: "Research"
1116
- }
1289
+ field_affinity_group_category: "Research",
1290
+ },
1117
1291
  },
1118
1292
  ],
1119
1293
  });
@@ -1126,26 +1300,25 @@ describe("AnnouncementsServer", () => {
1126
1300
  },
1127
1301
  });
1128
1302
 
1303
+ // Only 1 call — affinity groups view, no tags fetch
1304
+ expect(mockDrupalAuth.get).toHaveBeenCalledTimes(1);
1305
+ expect(mockDrupalAuth.get).toHaveBeenCalledWith(
1306
+ "/jsonapi/views/mcp_my_affinity_groups/page_1"
1307
+ );
1308
+
1129
1309
  const responseData = JSON.parse((result.content[0] as TextContent).text);
1130
- expect(responseData.tags).toHaveLength(2);
1131
- expect(responseData.tags[0].name).toBe("gpu");
1310
+ // Should NOT return tags
1311
+ expect(responseData).not.toHaveProperty("tags");
1132
1312
  expect(responseData.affinity_groups).toHaveLength(1);
1133
1313
  expect(responseData.affinity_groups[0].name).toBe("Test Group");
1134
1314
  expect(responseData.is_coordinator).toBe(true);
1135
1315
  expect(responseData.affiliations).toContain("ACCESS Collaboration");
1136
1316
  expect(responseData.affiliations).toContain("Community");
1317
+ expect(responseData.where_to_share_options).toHaveLength(4);
1318
+ expect(responseData.guidance).toContain("coordinator");
1137
1319
  });
1138
1320
 
1139
- it("should indicate non-coordinator when user has no affinity groups", async () => {
1140
- // First call: get user UUID
1141
- mockDrupalAuth.get.mockResolvedValueOnce({
1142
- data: [{ id: "user-uuid-123", attributes: { name: "testuser" } }],
1143
- });
1144
- // Second call: get tags (parallel)
1145
- mockDrupalAuth.get.mockResolvedValueOnce({
1146
- data: [{ id: "tag-1", attributes: { name: "gpu" } }],
1147
- });
1148
- // Third call: get affinity groups - empty (parallel)
1321
+ it("should indicate non-coordinator when views returns no affinity groups", async () => {
1149
1322
  mockDrupalAuth.get.mockResolvedValueOnce({
1150
1323
  data: [],
1151
1324
  });
@@ -1161,6 +1334,154 @@ describe("AnnouncementsServer", () => {
1161
1334
  const responseData = JSON.parse((result.content[0] as TextContent).text);
1162
1335
  expect(responseData.is_coordinator).toBe(false);
1163
1336
  expect(responseData.affinity_groups).toHaveLength(0);
1337
+ expect(responseData.guidance).toContain("not a coordinator");
1338
+ });
1339
+
1340
+ it("should fail without acting user", async () => {
1341
+ delete process.env.ACTING_USER;
1342
+
1343
+ const result = await server["handleToolCall"]({
1344
+ method: "tools/call",
1345
+ params: {
1346
+ name: "get_announcement_context",
1347
+ arguments: {},
1348
+ },
1349
+ });
1350
+
1351
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1352
+ expect(responseData.error).toContain("No acting user specified");
1353
+ expect(mockDrupalAuth.get).not.toHaveBeenCalled();
1354
+ });
1355
+
1356
+ it("should use acting user from request context", async () => {
1357
+ delete process.env.ACTING_USER;
1358
+
1359
+ mockDrupalAuth.get.mockResolvedValueOnce({ data: [] });
1360
+
1361
+ const context: RequestContext = {
1362
+ actingUser: "contextuser@access-ci.org",
1363
+ };
1364
+
1365
+ const result = await requestContextStorage.run(context, async () => {
1366
+ return server["handleToolCall"]({
1367
+ method: "tools/call",
1368
+ params: {
1369
+ name: "get_announcement_context",
1370
+ arguments: {},
1371
+ },
1372
+ });
1373
+ });
1374
+
1375
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1376
+ expect(responseData).not.toHaveProperty("tags");
1377
+ expect(responseData.is_coordinator).toBe(false);
1378
+ expect(mockDrupalAuth.get).toHaveBeenCalledTimes(1);
1379
+ });
1380
+ });
1381
+
1382
+ describe("suggest_tags", () => {
1383
+ it("should return error when text is too short", async () => {
1384
+ const result = await server["handleToolCall"]({
1385
+ method: "tools/call",
1386
+ params: {
1387
+ name: "suggest_tags",
1388
+ arguments: { text: "Short text" },
1389
+ },
1390
+ });
1391
+
1392
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1393
+ expect(responseData.error).toContain("at least 100 characters");
1394
+ });
1395
+
1396
+ it("should return error when text is empty", async () => {
1397
+ const result = await server["handleToolCall"]({
1398
+ method: "tools/call",
1399
+ params: {
1400
+ name: "suggest_tags",
1401
+ arguments: { text: "" },
1402
+ },
1403
+ });
1404
+
1405
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1406
+ expect(responseData.error).toContain("at least 100 characters");
1407
+ });
1408
+
1409
+ it("should return suggested tags on success", async () => {
1410
+ const longText = "A".repeat(150);
1411
+ mockDrupalAuth.post.mockResolvedValueOnce({
1412
+ tags: [
1413
+ { tid: 1, name: "ai", uuid: "uuid-1" },
1414
+ { tid: 2, name: "hpc", uuid: "uuid-2" },
1415
+ ],
1416
+ });
1417
+
1418
+ const result = await server["handleToolCall"]({
1419
+ method: "tools/call",
1420
+ params: {
1421
+ name: "suggest_tags",
1422
+ arguments: { text: longText },
1423
+ },
1424
+ });
1425
+
1426
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1427
+ expect(responseData.suggested_tags).toEqual(["ai", "hpc"]);
1428
+ expect(responseData.tag_details).toHaveLength(2);
1429
+ });
1430
+
1431
+ it("should truncate results to the specified limit", async () => {
1432
+ const longText = "A".repeat(150);
1433
+ mockDrupalAuth.post.mockResolvedValueOnce({
1434
+ tags: [
1435
+ { tid: 1, name: "ai", uuid: "uuid-1" },
1436
+ { tid: 2, name: "hpc", uuid: "uuid-2" },
1437
+ { tid: 3, name: "gpu", uuid: "uuid-3" },
1438
+ ],
1439
+ });
1440
+
1441
+ const result = await server["handleToolCall"]({
1442
+ method: "tools/call",
1443
+ params: {
1444
+ name: "suggest_tags",
1445
+ arguments: { text: longText, limit: 2 },
1446
+ },
1447
+ });
1448
+
1449
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1450
+ expect(responseData.suggested_tags).toHaveLength(2);
1451
+ expect(responseData.tag_details).toHaveLength(2);
1452
+ });
1453
+ });
1454
+
1455
+ describe("suggest_summary", () => {
1456
+ it("should return error when text is too short", async () => {
1457
+ const result = await server["handleToolCall"]({
1458
+ method: "tools/call",
1459
+ params: {
1460
+ name: "suggest_summary",
1461
+ arguments: { text: "Short text" },
1462
+ },
1463
+ });
1464
+
1465
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1466
+ expect(responseData.error).toContain("at least 100 characters");
1467
+ });
1468
+
1469
+ it("should return summary on success", async () => {
1470
+ const longText = "A".repeat(150);
1471
+ mockDrupalAuth.post.mockResolvedValueOnce({
1472
+ summary: "A test summary of the content.",
1473
+ });
1474
+
1475
+ const result = await server["handleToolCall"]({
1476
+ method: "tools/call",
1477
+ params: {
1478
+ name: "suggest_summary",
1479
+ arguments: { text: longText },
1480
+ },
1481
+ });
1482
+
1483
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1484
+ expect(responseData.summary).toBe("A test summary of the content.");
1164
1485
  });
1165
1486
  });
1166
1487
  });
@@ -1248,7 +1569,9 @@ describe("AnnouncementsServer", () => {
1248
1569
  },
1249
1570
  });
1250
1571
 
1572
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- MCP SDK prompt message content type
1251
1573
  expect((result.messages[0].content as any).text).toContain("GPU availability");
1574
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- MCP SDK prompt message content type
1252
1575
  expect((result.messages[1].content as any).text).toContain("GPU availability");
1253
1576
  });
1254
1577
 
@@ -1262,6 +1585,7 @@ describe("AnnouncementsServer", () => {
1262
1585
 
1263
1586
  expect(result.description).toBe("Guide for managing existing announcements");
1264
1587
  expect(result.messages).toHaveLength(2);
1588
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- MCP SDK prompt message content type
1265
1589
  expect((result.messages[1].content as any).text).toContain("get_my_announcements");
1266
1590
  });
1267
1591
 
@@ -1291,4 +1615,4 @@ describe("AnnouncementsServer", () => {
1291
1615
  expect(responseData.error).toContain("Unknown tool");
1292
1616
  });
1293
1617
  });
1294
- });
1618
+ });