@access-mcp/announcements 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,18 +1,46 @@
1
- import { describe, it, expect, beforeEach, vi } from "vitest";
1
+ import { describe, it, expect, beforeEach, afterEach, vi, Mock } from "vitest";
2
2
  import { AnnouncementsServer } from "./server.js";
3
+ import { DrupalAuthProvider, requestContextStorage, RequestContext } from "@access-mcp/shared";
4
+
5
+ // Mock the DrupalAuthProvider
6
+ vi.mock("@access-mcp/shared", async () => {
7
+ const actual = await vi.importActual("@access-mcp/shared");
8
+ return {
9
+ ...actual,
10
+ DrupalAuthProvider: vi.fn().mockImplementation(() => ({
11
+ ensureAuthenticated: vi.fn().mockResolvedValue(undefined),
12
+ getUserUuid: vi.fn().mockReturnValue("user-uuid-123"),
13
+ setActingUser: vi.fn(),
14
+ getActingUser: vi.fn(),
15
+ get: vi.fn(),
16
+ post: vi.fn(),
17
+ patch: vi.fn(),
18
+ delete: vi.fn(),
19
+ })),
20
+ };
21
+ });
22
+
23
+ interface MockHttpClient {
24
+ get: Mock<(url: string) => Promise<{ status: number; data?: unknown; statusText?: string }>>;
25
+ }
26
+
27
+ interface TextContent {
28
+ type: "text";
29
+ text: string;
30
+ }
3
31
 
4
32
  describe("AnnouncementsServer", () => {
5
33
  let server: AnnouncementsServer;
6
- let mockHttpClient: any;
34
+ let mockHttpClient: MockHttpClient;
7
35
 
8
36
  beforeEach(() => {
9
37
  server = new AnnouncementsServer();
10
-
38
+
11
39
  // Mock the httpClient
12
40
  mockHttpClient = {
13
41
  get: vi.fn(),
14
42
  };
15
-
43
+
16
44
  // Override the httpClient getter
17
45
  Object.defineProperty(server, "httpClient", {
18
46
  get: () => mockHttpClient,
@@ -48,6 +76,7 @@ describe("AnnouncementsServer", () => {
48
76
  mockHttpClient.get.mockResolvedValue(mockResponse);
49
77
 
50
78
  const result = await server["handleToolCall"]({
79
+ method: "tools/call",
51
80
  params: {
52
81
  name: "search_announcements",
53
82
  arguments: {
@@ -55,14 +84,14 @@ describe("AnnouncementsServer", () => {
55
84
  limit: 10,
56
85
  },
57
86
  },
58
- } as any);
87
+ });
59
88
 
60
89
  expect(mockHttpClient.get).toHaveBeenCalled();
61
90
  const url = mockHttpClient.get.mock.calls[0][0];
62
91
  expect(url).toContain("/api/2.2/announcements");
63
92
  expect(url).toContain("tags=maintenance");
64
93
 
65
- const responseData = JSON.parse(result.content[0].text);
94
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
66
95
  expect(responseData.total).toBe(2);
67
96
  expect(responseData.items).toHaveLength(2);
68
97
  expect(responseData.items[0].tags).toEqual(["maintenance", "scheduled"]);
@@ -75,15 +104,16 @@ describe("AnnouncementsServer", () => {
75
104
  });
76
105
 
77
106
  const result = await server["handleToolCall"]({
107
+ method: "tools/call",
78
108
  params: {
79
109
  name: "search_announcements",
80
110
  arguments: {
81
111
  tags: "nonexistent",
82
112
  },
83
113
  },
84
- } as any);
114
+ });
85
115
 
86
- const responseData = JSON.parse(result.content[0].text);
116
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
87
117
  expect(responseData.total).toBe(0);
88
118
  expect(responseData.items).toEqual([]);
89
119
  });
@@ -95,14 +125,61 @@ describe("AnnouncementsServer", () => {
95
125
  });
96
126
 
97
127
  const result = await server["handleToolCall"]({
128
+ method: "tools/call",
98
129
  params: {
99
130
  name: "search_announcements",
100
131
  arguments: {},
101
132
  },
102
- } as any);
133
+ });
103
134
 
104
135
  // Server handles errors and returns them in content, not as isError
105
- expect(result.content[0].text).toContain("500");
136
+ expect((result.content[0] as TextContent).text).toContain("500");
137
+ });
138
+ });
139
+
140
+ describe("search with query parameter", () => {
141
+ it("should include search_api_fulltext in URL", async () => {
142
+ mockHttpClient.get.mockResolvedValue({
143
+ status: 200,
144
+ data: [],
145
+ });
146
+
147
+ await server["handleToolCall"]({
148
+ method: "tools/call",
149
+ params: {
150
+ name: "search_announcements",
151
+ arguments: {
152
+ query: "GPU computing",
153
+ },
154
+ },
155
+ });
156
+
157
+ const url = mockHttpClient.get.mock.calls[0][0];
158
+ expect(url).toContain("search_api_fulltext=GPU+computing");
159
+ });
160
+
161
+ it("should combine query with other filters", async () => {
162
+ mockHttpClient.get.mockResolvedValue({
163
+ status: 200,
164
+ data: [],
165
+ });
166
+
167
+ await server["handleToolCall"]({
168
+ method: "tools/call",
169
+ params: {
170
+ name: "search_announcements",
171
+ arguments: {
172
+ query: "workshop",
173
+ tags: "training",
174
+ date: "this_month",
175
+ },
176
+ },
177
+ });
178
+
179
+ const url = mockHttpClient.get.mock.calls[0][0];
180
+ expect(url).toContain("search_api_fulltext=workshop");
181
+ expect(url).toContain("tags=training");
182
+ expect(url).toContain("relative_start_date=-1+month");
106
183
  });
107
184
  });
108
185
 
@@ -125,6 +202,7 @@ describe("AnnouncementsServer", () => {
125
202
  mockHttpClient.get.mockResolvedValue(mockResponse);
126
203
 
127
204
  const result = await server["handleToolCall"]({
205
+ method: "tools/call",
128
206
  params: {
129
207
  name: "search_announcements",
130
208
  arguments: {
@@ -132,12 +210,12 @@ describe("AnnouncementsServer", () => {
132
210
  limit: 20,
133
211
  },
134
212
  },
135
- } as any);
213
+ });
136
214
 
137
215
  const url = mockHttpClient.get.mock.calls[0][0];
138
216
  expect(url).toContain("tags=gpu%2Cmaintenance");
139
217
 
140
- const responseData = JSON.parse(result.content[0].text);
218
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
141
219
  expect(responseData.items[0].tags).toContain("gpu");
142
220
  expect(responseData.items[0].tags).toContain("maintenance");
143
221
  });
@@ -161,18 +239,19 @@ describe("AnnouncementsServer", () => {
161
239
  mockHttpClient.get.mockResolvedValue(mockResponse);
162
240
 
163
241
  const result = await server["handleToolCall"]({
242
+ method: "tools/call",
164
243
  params: {
165
244
  name: "search_announcements",
166
245
  arguments: {
167
246
  limit: 5,
168
247
  },
169
248
  },
170
- } as any);
249
+ });
171
250
 
172
251
  const url = mockHttpClient.get.mock.calls[0][0];
173
252
  expect(url).toContain("items_per_page=5");
174
253
 
175
- const responseData = JSON.parse(result.content[0].text);
254
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
176
255
  expect(responseData.items).toHaveLength(1);
177
256
  });
178
257
  });
@@ -196,18 +275,19 @@ describe("AnnouncementsServer", () => {
196
275
  mockHttpClient.get.mockResolvedValue(mockResponse);
197
276
 
198
277
  const result = await server["handleToolCall"]({
278
+ method: "tools/call",
199
279
  params: {
200
280
  name: "search_announcements",
201
281
  arguments: {
202
282
  date: "this_week",
203
283
  },
204
284
  },
205
- } as any);
285
+ });
206
286
 
207
287
  const url = mockHttpClient.get.mock.calls[0][0];
208
288
  expect(url).toContain("relative_start_date=-1+week");
209
289
 
210
- const responseData = JSON.parse(result.content[0].text);
290
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
211
291
  expect(responseData.items).toHaveLength(1);
212
292
  });
213
293
 
@@ -218,11 +298,12 @@ describe("AnnouncementsServer", () => {
218
298
  });
219
299
 
220
300
  await server["handleToolCall"]({
301
+ method: "tools/call",
221
302
  params: {
222
303
  name: "search_announcements",
223
304
  arguments: {},
224
305
  },
225
- } as any);
306
+ });
226
307
 
227
308
  expect(mockHttpClient.get).toHaveBeenCalled();
228
309
  const url = mockHttpClient.get.mock.calls[0][0];
@@ -239,6 +320,7 @@ describe("AnnouncementsServer", () => {
239
320
  });
240
321
 
241
322
  await server["handleToolCall"]({
323
+ method: "tools/call",
242
324
  params: {
243
325
  name: "search_announcements",
244
326
  arguments: {
@@ -247,7 +329,7 @@ describe("AnnouncementsServer", () => {
247
329
  limit: 20,
248
330
  },
249
331
  },
250
- } as any);
332
+ });
251
333
 
252
334
  const url = mockHttpClient.get.mock.calls[0][0];
253
335
  expect(url).toContain("tags=gpu%2Cmaintenance");
@@ -261,13 +343,14 @@ describe("AnnouncementsServer", () => {
261
343
  });
262
344
 
263
345
  await server["handleToolCall"]({
346
+ method: "tools/call",
264
347
  params: {
265
348
  name: "search_announcements",
266
349
  arguments: {
267
350
  date: "today",
268
351
  },
269
352
  },
270
- } as any);
353
+ });
271
354
 
272
355
  const url = mockHttpClient.get.mock.calls[0][0];
273
356
  expect(url).toContain("relative_start_date=today");
@@ -275,7 +358,7 @@ describe("AnnouncementsServer", () => {
275
358
  });
276
359
 
277
360
  describe("Data Enhancement", () => {
278
- it("should parse tags correctly", async () => {
361
+ it("should parse tags array correctly", async () => {
279
362
  const mockResponse = {
280
363
  status: 200,
281
364
  data: [
@@ -291,23 +374,93 @@ describe("AnnouncementsServer", () => {
291
374
  mockHttpClient.get.mockResolvedValue(mockResponse);
292
375
 
293
376
  const result = await server["handleToolCall"]({
377
+ method: "tools/call",
294
378
  params: {
295
379
  name: "search_announcements",
296
380
  arguments: {},
297
381
  },
298
- } as any);
382
+ });
299
383
 
300
- const responseData = JSON.parse(result.content[0].text);
384
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
301
385
  expect(responseData.items[0].tags).toEqual(["tag1", "tag2", "tag3"]);
302
386
  });
303
387
 
388
+ it("should parse tags from comma-separated string", async () => {
389
+ const mockResponse = {
390
+ status: 200,
391
+ data: [
392
+ {
393
+ title: "Test",
394
+ published_date: "2024-03-15",
395
+ tags: "gpu, machine-learning, hpc",
396
+ affinity_group: [],
397
+ },
398
+ ],
399
+ };
400
+
401
+ mockHttpClient.get.mockResolvedValue(mockResponse);
402
+
403
+ const result = await server["handleToolCall"]({
404
+ method: "tools/call",
405
+ params: {
406
+ name: "search_announcements",
407
+ arguments: {},
408
+ },
409
+ });
410
+
411
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
412
+ expect(responseData.items[0].tags).toEqual(["gpu", "machine-learning", "hpc"]);
413
+ });
414
+
415
+ it("should handle empty tags string", async () => {
416
+ const mockResponse = {
417
+ status: 200,
418
+ data: [
419
+ {
420
+ title: "Test",
421
+ published_date: "2024-03-15",
422
+ tags: "",
423
+ affinity_group: [],
424
+ },
425
+ ],
426
+ };
427
+
428
+ mockHttpClient.get.mockResolvedValue(mockResponse);
429
+
430
+ const result = await server["handleToolCall"]({
431
+ method: "tools/call",
432
+ params: {
433
+ name: "search_announcements",
434
+ arguments: {},
435
+ },
436
+ });
437
+
438
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
439
+ expect(responseData.items[0].tags).toEqual([]);
440
+ });
441
+
304
442
  it("should extract popular tags", async () => {
305
443
  const mockResponse = {
306
444
  status: 200,
307
445
  data: [
308
- { title: "1", published_date: "2024-03-15", tags: ["gpu", "maintenance"], affinity_group: [] },
309
- { title: "2", published_date: "2024-03-14", tags: ["gpu", "network"], affinity_group: [] },
310
- { title: "3", published_date: "2024-03-13", tags: ["gpu", "storage"], affinity_group: [] },
446
+ {
447
+ title: "1",
448
+ published_date: "2024-03-15",
449
+ tags: ["gpu", "maintenance"],
450
+ affinity_group: [],
451
+ },
452
+ {
453
+ title: "2",
454
+ published_date: "2024-03-14",
455
+ tags: ["gpu", "network"],
456
+ affinity_group: [],
457
+ },
458
+ {
459
+ title: "3",
460
+ published_date: "2024-03-13",
461
+ tags: ["gpu", "storage"],
462
+ affinity_group: [],
463
+ },
311
464
  { title: "4", published_date: "2024-03-12", tags: ["maintenance"], affinity_group: [] },
312
465
  ],
313
466
  };
@@ -315,13 +468,14 @@ describe("AnnouncementsServer", () => {
315
468
  mockHttpClient.get.mockResolvedValue(mockResponse);
316
469
 
317
470
  const result = await server["handleToolCall"]({
471
+ method: "tools/call",
318
472
  params: {
319
473
  name: "search_announcements",
320
474
  arguments: {},
321
475
  },
322
- } as any);
476
+ });
323
477
 
324
- const responseData = JSON.parse(result.content[0].text);
478
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
325
479
  // Popular tags are in metadata, not in the universal {total, items} format
326
480
  expect(responseData.items).toHaveLength(4);
327
481
  });
@@ -342,14 +496,1024 @@ describe("AnnouncementsServer", () => {
342
496
  mockHttpClient.get.mockResolvedValue(mockResponse);
343
497
 
344
498
  const result = await server["handleToolCall"]({
499
+ method: "tools/call",
345
500
  params: {
346
501
  name: "search_announcements",
347
502
  arguments: {},
348
503
  },
349
- } as any);
504
+ });
350
505
 
351
- const responseData = JSON.parse(result.content[0].text);
506
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
352
507
  expect(responseData.items[0].published_date).toBe("2024-03-15");
353
508
  });
354
509
  });
355
- });
510
+
511
+ describe("CRUD Operations", () => {
512
+ let mockDrupalAuth: {
513
+ ensureAuthenticated: Mock;
514
+ getUserUuid: Mock;
515
+ setActingUser: Mock;
516
+ getActingUser: Mock;
517
+ get: Mock;
518
+ post: Mock;
519
+ patch: Mock;
520
+ delete: Mock;
521
+ };
522
+
523
+ beforeEach(() => {
524
+ // Set up environment variables for CRUD operations
525
+ process.env.DRUPAL_API_URL = "https://test.drupal.site";
526
+ process.env.DRUPAL_USERNAME = "test_user";
527
+ process.env.DRUPAL_PASSWORD = "test_password";
528
+ process.env.ACTING_USER = "testuser@access-ci.org";
529
+
530
+ // Create a fresh mock for each test
531
+ mockDrupalAuth = {
532
+ ensureAuthenticated: vi.fn().mockResolvedValue(undefined),
533
+ getUserUuid: vi.fn().mockReturnValue("user-uuid-123"),
534
+ setActingUser: vi.fn(),
535
+ getActingUser: vi.fn(),
536
+ get: vi.fn(),
537
+ post: vi.fn(),
538
+ patch: vi.fn(),
539
+ delete: vi.fn(),
540
+ };
541
+
542
+ // Mock the DrupalAuthProvider constructor to return our mock
543
+ (DrupalAuthProvider as unknown as Mock).mockImplementation(() => mockDrupalAuth);
544
+ });
545
+
546
+ afterEach(() => {
547
+ delete process.env.DRUPAL_API_URL;
548
+ delete process.env.DRUPAL_USERNAME;
549
+ delete process.env.DRUPAL_PASSWORD;
550
+ delete process.env.ACTING_USER;
551
+ });
552
+
553
+ describe("create_announcement", () => {
554
+ it("should create an announcement with required fields", async () => {
555
+ mockDrupalAuth.post.mockResolvedValue({
556
+ data: {
557
+ id: "new-announcement-uuid",
558
+ attributes: {
559
+ title: "Test Announcement",
560
+ drupal_internal__nid: 12345,
561
+ },
562
+ },
563
+ });
564
+
565
+ const result = await server["handleToolCall"]({
566
+ method: "tools/call",
567
+ params: {
568
+ name: "create_announcement",
569
+ arguments: {
570
+ title: "Test Announcement",
571
+ body: "<p>This is a test</p>",
572
+ summary: "Test summary",
573
+ },
574
+ },
575
+ });
576
+
577
+ expect(mockDrupalAuth.post).toHaveBeenCalledWith(
578
+ "/jsonapi/node/access_news",
579
+ expect.objectContaining({
580
+ data: expect.objectContaining({
581
+ type: "node--access_news",
582
+ attributes: expect.objectContaining({
583
+ title: "Test Announcement",
584
+ moderation_state: "draft",
585
+ body: expect.objectContaining({
586
+ value: "<p>This is a test</p>",
587
+ format: "basic_html",
588
+ summary: "Test summary",
589
+ }),
590
+ }),
591
+ }),
592
+ })
593
+ );
594
+
595
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
596
+ expect(responseData.success).toBe(true);
597
+ expect(responseData.uuid).toBe("new-announcement-uuid");
598
+ });
599
+
600
+ it("should look up tags by name when provided (with caching)", async () => {
601
+ mockDrupalAuth.get.mockResolvedValueOnce({
602
+ data: [
603
+ { id: "tag-uuid-1", attributes: { name: "gpu" } },
604
+ { id: "tag-uuid-2", attributes: { name: "maintenance" } },
605
+ { id: "tag-uuid-3", attributes: { name: "hpc" } },
606
+ ],
607
+ });
608
+
609
+ mockDrupalAuth.post.mockResolvedValue({
610
+ data: {
611
+ id: "new-announcement-uuid",
612
+ attributes: { title: "Test" },
613
+ },
614
+ });
615
+
616
+ await server["handleToolCall"]({
617
+ method: "tools/call",
618
+ params: {
619
+ name: "create_announcement",
620
+ arguments: {
621
+ title: "Test",
622
+ body: "Body",
623
+ summary: "Summary",
624
+ tags: ["gpu", "maintenance"],
625
+ },
626
+ },
627
+ });
628
+
629
+ expect(mockDrupalAuth.get).toHaveBeenCalledWith(
630
+ "/jsonapi/taxonomy_term/tags?page[limit]=500"
631
+ );
632
+
633
+ expect(mockDrupalAuth.post).toHaveBeenCalledWith(
634
+ "/jsonapi/node/access_news",
635
+ expect.objectContaining({
636
+ data: expect.objectContaining({
637
+ relationships: expect.objectContaining({
638
+ field_tags: {
639
+ data: [
640
+ { type: "taxonomy_term--tags", id: "tag-uuid-1" },
641
+ { type: "taxonomy_term--tags", id: "tag-uuid-2" },
642
+ ],
643
+ },
644
+ }),
645
+ }),
646
+ })
647
+ );
648
+ });
649
+
650
+ it("should fail without credentials", async () => {
651
+ delete process.env.DRUPAL_API_URL;
652
+
653
+ const result = await server["handleToolCall"]({
654
+ method: "tools/call",
655
+ params: {
656
+ name: "create_announcement",
657
+ arguments: {
658
+ title: "Test",
659
+ body: "Body",
660
+ },
661
+ },
662
+ });
663
+
664
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
665
+ expect(responseData.error).toContain("DRUPAL_API_URL");
666
+ });
667
+
668
+ it("should fail without ACTING_USER", async () => {
669
+ delete process.env.ACTING_USER;
670
+
671
+ const result = await server["handleToolCall"]({
672
+ method: "tools/call",
673
+ params: {
674
+ name: "get_my_announcements",
675
+ arguments: {},
676
+ },
677
+ });
678
+
679
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
680
+ expect(responseData.error).toContain("No acting user specified");
681
+ });
682
+
683
+ it("should use actingUser from request context when env var not set", async () => {
684
+ delete process.env.ACTING_USER;
685
+
686
+ mockDrupalAuth.post.mockResolvedValue({
687
+ data: {
688
+ id: "new-announcement-uuid",
689
+ attributes: {
690
+ title: "Test",
691
+ drupal_internal__nid: 12345,
692
+ },
693
+ },
694
+ });
695
+
696
+ const context: RequestContext = {
697
+ actingUser: "contextuser@access-ci.org",
698
+ };
699
+
700
+ const result = await requestContextStorage.run(context, async () => {
701
+ return server["handleToolCall"]({
702
+ method: "tools/call",
703
+ params: {
704
+ name: "create_announcement",
705
+ arguments: {
706
+ title: "Test",
707
+ body: "Body",
708
+ summary: "Summary",
709
+ },
710
+ },
711
+ });
712
+ });
713
+
714
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
715
+ expect(responseData.success).toBe(true);
716
+ });
717
+
718
+ it("should prefer request context actingUser over env var", async () => {
719
+ process.env.ACTING_USER = "envuser@access-ci.org";
720
+
721
+ mockDrupalAuth.post.mockResolvedValue({
722
+ data: {
723
+ id: "new-announcement-uuid",
724
+ attributes: { title: "Test" },
725
+ },
726
+ });
727
+
728
+ const context: RequestContext = {
729
+ actingUser: "contextuser@access-ci.org",
730
+ };
731
+
732
+ await requestContextStorage.run(context, async () => {
733
+ return server["handleToolCall"]({
734
+ method: "tools/call",
735
+ params: {
736
+ name: "create_announcement",
737
+ arguments: {
738
+ title: "Test",
739
+ body: "Body",
740
+ summary: "Summary",
741
+ },
742
+ },
743
+ });
744
+ });
745
+
746
+ // Should have called setActingUser with the context value, not env var
747
+ expect(mockDrupalAuth.setActingUser).toHaveBeenCalledWith("contextuser@access-ci.org");
748
+ });
749
+
750
+ it("should set actingUser on DrupalAuth from request context", async () => {
751
+ const context: RequestContext = {
752
+ actingUser: "researcher@access-ci.org",
753
+ };
754
+
755
+ mockDrupalAuth.post.mockResolvedValue({
756
+ data: {
757
+ id: "new-announcement-uuid",
758
+ attributes: { title: "Test" },
759
+ },
760
+ });
761
+
762
+ await requestContextStorage.run(context, async () => {
763
+ return server["handleToolCall"]({
764
+ method: "tools/call",
765
+ params: {
766
+ name: "create_announcement",
767
+ arguments: {
768
+ title: "Test",
769
+ body: "Body",
770
+ summary: "Summary",
771
+ },
772
+ },
773
+ });
774
+ });
775
+
776
+ expect(mockDrupalAuth.setActingUser).toHaveBeenCalledWith("researcher@access-ci.org");
777
+ });
778
+
779
+ it("should create announcement with external link", async () => {
780
+ mockDrupalAuth.post.mockResolvedValue({
781
+ data: {
782
+ id: "new-announcement-uuid",
783
+ attributes: {
784
+ title: "Test with Link",
785
+ drupal_internal__nid: 12345,
786
+ },
787
+ },
788
+ });
789
+
790
+ await server["handleToolCall"]({
791
+ method: "tools/call",
792
+ params: {
793
+ name: "create_announcement",
794
+ arguments: {
795
+ title: "Test with Link",
796
+ body: "<p>Body content</p>",
797
+ summary: "Test summary",
798
+ external_link: {
799
+ uri: "https://example.com/resource",
800
+ title: "Learn more",
801
+ },
802
+ },
803
+ },
804
+ });
805
+
806
+ expect(mockDrupalAuth.post).toHaveBeenCalledWith(
807
+ "/jsonapi/node/access_news",
808
+ expect.objectContaining({
809
+ data: expect.objectContaining({
810
+ attributes: expect.objectContaining({
811
+ field_news_external_link: {
812
+ uri: "https://example.com/resource",
813
+ title: "Learn more",
814
+ },
815
+ }),
816
+ }),
817
+ })
818
+ );
819
+ });
820
+
821
+ it("should create announcement with where_to_share", async () => {
822
+ mockDrupalAuth.post.mockResolvedValue({
823
+ data: {
824
+ id: "new-announcement-uuid",
825
+ attributes: {
826
+ title: "Test with sharing",
827
+ drupal_internal__nid: 12345,
828
+ },
829
+ },
830
+ });
831
+
832
+ await server["handleToolCall"]({
833
+ method: "tools/call",
834
+ params: {
835
+ name: "create_announcement",
836
+ arguments: {
837
+ title: "Test with sharing",
838
+ body: "<p>Body content</p>",
839
+ summary: "Test summary",
840
+ where_to_share: ["Announcements page", "Bi-Weekly Digest"],
841
+ },
842
+ },
843
+ });
844
+
845
+ expect(mockDrupalAuth.post).toHaveBeenCalledWith(
846
+ "/jsonapi/node/access_news",
847
+ expect.objectContaining({
848
+ data: expect.objectContaining({
849
+ attributes: expect.objectContaining({
850
+ field_choose_where_to_share_this: [
851
+ "on_the_announcements_page",
852
+ "in_the_access_support_bi_weekly_digest",
853
+ ],
854
+ }),
855
+ }),
856
+ })
857
+ );
858
+ });
859
+
860
+ it("should fail with invalid where_to_share value", async () => {
861
+ const result = await server["handleToolCall"]({
862
+ method: "tools/call",
863
+ params: {
864
+ name: "create_announcement",
865
+ arguments: {
866
+ title: "Test",
867
+ body: "Body",
868
+ summary: "Summary",
869
+ where_to_share: ["Invalid Option"],
870
+ },
871
+ },
872
+ });
873
+
874
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
875
+ expect(responseData.error).toContain("Invalid where_to_share value");
876
+ });
877
+
878
+ it("should create announcement with affinity group", async () => {
879
+ // Affinity group lookup by title (first by field_group_id returns empty, then by title)
880
+ mockDrupalAuth.get.mockResolvedValueOnce({ data: [] });
881
+ mockDrupalAuth.get.mockResolvedValueOnce({
882
+ data: [{ id: "group-uuid-456", attributes: { title: "Test Group" } }],
883
+ });
884
+
885
+ mockDrupalAuth.post.mockResolvedValue({
886
+ data: {
887
+ id: "new-announcement-uuid",
888
+ attributes: {
889
+ title: "Test with group",
890
+ drupal_internal__nid: 12345,
891
+ },
892
+ },
893
+ });
894
+
895
+ await server["handleToolCall"]({
896
+ method: "tools/call",
897
+ params: {
898
+ name: "create_announcement",
899
+ arguments: {
900
+ title: "Test with group",
901
+ body: "<p>Body content</p>",
902
+ summary: "Test summary",
903
+ affinity_group: "Test Group",
904
+ },
905
+ },
906
+ });
907
+
908
+ expect(mockDrupalAuth.post).toHaveBeenCalledWith(
909
+ "/jsonapi/node/access_news",
910
+ expect.objectContaining({
911
+ data: expect.objectContaining({
912
+ relationships: expect.objectContaining({
913
+ field_affinity_group_node: {
914
+ data: {
915
+ type: "node--affinity_group",
916
+ id: "group-uuid-456",
917
+ },
918
+ },
919
+ }),
920
+ }),
921
+ })
922
+ );
923
+ });
924
+
925
+ it("should fail when affinity group not found", async () => {
926
+ // Both lookups return empty
927
+ mockDrupalAuth.get.mockResolvedValueOnce({ data: [] });
928
+ mockDrupalAuth.get.mockResolvedValueOnce({ data: [] });
929
+
930
+ const result = await server["handleToolCall"]({
931
+ method: "tools/call",
932
+ params: {
933
+ name: "create_announcement",
934
+ arguments: {
935
+ title: "Test",
936
+ body: "Body",
937
+ summary: "Summary",
938
+ affinity_group: "Nonexistent Group",
939
+ },
940
+ },
941
+ });
942
+
943
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
944
+ expect(responseData.error).toContain("Affinity group not found");
945
+ });
946
+ });
947
+
948
+ describe("update_announcement", () => {
949
+ it("should update an announcement", async () => {
950
+ mockDrupalAuth.patch.mockResolvedValue({
951
+ data: {
952
+ id: "announcement-uuid",
953
+ attributes: { title: "Updated Title" },
954
+ },
955
+ });
956
+
957
+ const result = await server["handleToolCall"]({
958
+ method: "tools/call",
959
+ params: {
960
+ name: "update_announcement",
961
+ arguments: {
962
+ uuid: "announcement-uuid",
963
+ title: "Updated Title",
964
+ },
965
+ },
966
+ });
967
+
968
+ expect(mockDrupalAuth.patch).toHaveBeenCalledWith(
969
+ "/jsonapi/node/access_news/announcement-uuid",
970
+ expect.objectContaining({
971
+ data: expect.objectContaining({
972
+ id: "announcement-uuid",
973
+ attributes: expect.objectContaining({
974
+ title: "Updated Title",
975
+ }),
976
+ }),
977
+ })
978
+ );
979
+
980
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
981
+ expect(responseData.success).toBe(true);
982
+ });
983
+
984
+ it("should preserve existing body when updating summary only", async () => {
985
+ // First call: fetch existing announcement
986
+ mockDrupalAuth.get.mockResolvedValueOnce({
987
+ data: {
988
+ attributes: {
989
+ body: {
990
+ value: "<p>Existing body content</p>",
991
+ summary: "Old summary",
992
+ },
993
+ },
994
+ },
995
+ });
996
+
997
+ mockDrupalAuth.patch.mockResolvedValue({
998
+ data: {
999
+ id: "announcement-uuid",
1000
+ attributes: { title: "Test" },
1001
+ },
1002
+ });
1003
+
1004
+ await server["handleToolCall"]({
1005
+ method: "tools/call",
1006
+ params: {
1007
+ name: "update_announcement",
1008
+ arguments: {
1009
+ uuid: "announcement-uuid",
1010
+ summary: "New summary only",
1011
+ },
1012
+ },
1013
+ });
1014
+
1015
+ expect(mockDrupalAuth.patch).toHaveBeenCalledWith(
1016
+ "/jsonapi/node/access_news/announcement-uuid",
1017
+ expect.objectContaining({
1018
+ data: expect.objectContaining({
1019
+ attributes: expect.objectContaining({
1020
+ body: {
1021
+ value: "<p>Existing body content</p>",
1022
+ format: "basic_html",
1023
+ summary: "New summary only",
1024
+ },
1025
+ }),
1026
+ }),
1027
+ })
1028
+ );
1029
+ });
1030
+
1031
+ it("should update with tags", async () => {
1032
+ // Tag cache fetch
1033
+ mockDrupalAuth.get.mockResolvedValueOnce({
1034
+ data: [
1035
+ { id: "tag-uuid-1", attributes: { name: "gpu" } },
1036
+ { id: "tag-uuid-2", attributes: { name: "hpc" } },
1037
+ ],
1038
+ });
1039
+
1040
+ mockDrupalAuth.patch.mockResolvedValue({
1041
+ data: {
1042
+ id: "announcement-uuid",
1043
+ attributes: { title: "Test" },
1044
+ },
1045
+ });
1046
+
1047
+ await server["handleToolCall"]({
1048
+ method: "tools/call",
1049
+ params: {
1050
+ name: "update_announcement",
1051
+ arguments: {
1052
+ uuid: "announcement-uuid",
1053
+ tags: ["gpu", "hpc"],
1054
+ },
1055
+ },
1056
+ });
1057
+
1058
+ expect(mockDrupalAuth.patch).toHaveBeenCalledWith(
1059
+ "/jsonapi/node/access_news/announcement-uuid",
1060
+ expect.objectContaining({
1061
+ data: expect.objectContaining({
1062
+ relationships: expect.objectContaining({
1063
+ field_tags: {
1064
+ data: [
1065
+ { type: "taxonomy_term--tags", id: "tag-uuid-1" },
1066
+ { type: "taxonomy_term--tags", id: "tag-uuid-2" },
1067
+ ],
1068
+ },
1069
+ }),
1070
+ }),
1071
+ })
1072
+ );
1073
+ });
1074
+ });
1075
+
1076
+ describe("delete_announcement", () => {
1077
+ it("should delete an announcement when confirmed", async () => {
1078
+ mockDrupalAuth.delete.mockResolvedValue({});
1079
+
1080
+ const result = await server["handleToolCall"]({
1081
+ method: "tools/call",
1082
+ params: {
1083
+ name: "delete_announcement",
1084
+ arguments: {
1085
+ uuid: "announcement-to-delete",
1086
+ confirmed: true,
1087
+ },
1088
+ },
1089
+ });
1090
+
1091
+ expect(mockDrupalAuth.delete).toHaveBeenCalledWith(
1092
+ "/jsonapi/node/access_news/announcement-to-delete"
1093
+ );
1094
+
1095
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1096
+ expect(responseData.success).toBe(true);
1097
+ expect(responseData.uuid).toBe("announcement-to-delete");
1098
+ });
1099
+
1100
+ it("should reject deletion without confirmation", async () => {
1101
+ const result = await server["handleToolCall"]({
1102
+ method: "tools/call",
1103
+ params: {
1104
+ name: "delete_announcement",
1105
+ arguments: {
1106
+ uuid: "announcement-to-delete",
1107
+ confirmed: false,
1108
+ },
1109
+ },
1110
+ });
1111
+
1112
+ // Should not call delete
1113
+ expect(mockDrupalAuth.delete).not.toHaveBeenCalled();
1114
+
1115
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1116
+ expect(responseData.error).toContain("explicit confirmation");
1117
+ });
1118
+ });
1119
+
1120
+ describe("get_my_announcements", () => {
1121
+ it("should fetch announcements via views endpoint without user UUID lookup", async () => {
1122
+ mockDrupalAuth.get.mockResolvedValueOnce({
1123
+ data: [
1124
+ {
1125
+ id: "announcement-1",
1126
+ attributes: {
1127
+ title: "My First Announcement",
1128
+ status: false,
1129
+ created: "2024-03-15T10:00:00Z",
1130
+ body: { value: "<p>Content</p>", summary: "Summary" },
1131
+ },
1132
+ },
1133
+ ],
1134
+ });
1135
+
1136
+ const result = await server["handleToolCall"]({
1137
+ method: "tools/call",
1138
+ params: {
1139
+ name: "get_my_announcements",
1140
+ arguments: { limit: 10 },
1141
+ },
1142
+ });
1143
+
1144
+ // Should make exactly 1 call — no user UUID lookup
1145
+ expect(mockDrupalAuth.get).toHaveBeenCalledTimes(1);
1146
+ expect(mockDrupalAuth.get).toHaveBeenCalledWith(
1147
+ "/jsonapi/views/mcp_my_announcements/page_1?page[limit]=10"
1148
+ );
1149
+
1150
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1151
+ expect(responseData.items).toHaveLength(1);
1152
+ expect(responseData.items[0].title).toBe("My First Announcement");
1153
+ expect(responseData.items[0].status).toBe("draft");
1154
+ });
1155
+
1156
+ it("should use default limit of 25", async () => {
1157
+ mockDrupalAuth.get.mockResolvedValueOnce({ data: [] });
1158
+
1159
+ await server["handleToolCall"]({
1160
+ method: "tools/call",
1161
+ params: {
1162
+ name: "get_my_announcements",
1163
+ arguments: {},
1164
+ },
1165
+ });
1166
+
1167
+ expect(mockDrupalAuth.get).toHaveBeenCalledWith(
1168
+ "/jsonapi/views/mcp_my_announcements/page_1?page[limit]=25"
1169
+ );
1170
+ });
1171
+
1172
+ it("should map published status and build edit_url from nid", async () => {
1173
+ mockDrupalAuth.get.mockResolvedValueOnce({
1174
+ data: [
1175
+ {
1176
+ id: "ann-published",
1177
+ attributes: {
1178
+ title: "Published One",
1179
+ status: true,
1180
+ drupal_internal__nid: 999,
1181
+ created: "2024-03-15T10:00:00Z",
1182
+ field_published_date: "2024-03-15",
1183
+ body: { value: "<p>Body</p>", summary: "Short summary" },
1184
+ },
1185
+ },
1186
+ {
1187
+ id: "ann-draft",
1188
+ attributes: {
1189
+ title: "Draft One",
1190
+ status: false,
1191
+ drupal_internal__nid: 1000,
1192
+ created: "2024-03-14T10:00:00Z",
1193
+ body: { value: "<p>Draft body</p>" },
1194
+ },
1195
+ },
1196
+ ],
1197
+ });
1198
+
1199
+ const result = await server["handleToolCall"]({
1200
+ method: "tools/call",
1201
+ params: {
1202
+ name: "get_my_announcements",
1203
+ arguments: {},
1204
+ },
1205
+ });
1206
+
1207
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1208
+ expect(responseData.total).toBe(2);
1209
+
1210
+ // Published announcement
1211
+ expect(responseData.items[0].uuid).toBe("ann-published");
1212
+ expect(responseData.items[0].status).toBe("published");
1213
+ expect(responseData.items[0].nid).toBe(999);
1214
+ expect(responseData.items[0].edit_url).toBe("https://test.drupal.site/node/999/edit");
1215
+ expect(responseData.items[0].published_date).toBe("2024-03-15");
1216
+ expect(responseData.items[0].summary).toBe("Short summary");
1217
+
1218
+ // Draft announcement — summary falls back to body text
1219
+ expect(responseData.items[1].uuid).toBe("ann-draft");
1220
+ expect(responseData.items[1].status).toBe("draft");
1221
+ expect(responseData.items[1].edit_url).toBe("https://test.drupal.site/node/1000/edit");
1222
+ expect(responseData.items[1].summary).toContain("Draft body");
1223
+ });
1224
+
1225
+ it("should handle empty results from views endpoint", async () => {
1226
+ mockDrupalAuth.get.mockResolvedValueOnce({ data: [] });
1227
+
1228
+ const result = await server["handleToolCall"]({
1229
+ method: "tools/call",
1230
+ params: {
1231
+ name: "get_my_announcements",
1232
+ arguments: {},
1233
+ },
1234
+ });
1235
+
1236
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1237
+ expect(responseData.total).toBe(0);
1238
+ expect(responseData.items).toEqual([]);
1239
+ });
1240
+
1241
+ it("should use acting user from request context", async () => {
1242
+ delete process.env.ACTING_USER;
1243
+
1244
+ mockDrupalAuth.get.mockResolvedValueOnce({ data: [] });
1245
+
1246
+ const context: RequestContext = {
1247
+ actingUser: "contextuser@access-ci.org",
1248
+ };
1249
+
1250
+ const result = await requestContextStorage.run(context, async () => {
1251
+ return server["handleToolCall"]({
1252
+ method: "tools/call",
1253
+ params: {
1254
+ name: "get_my_announcements",
1255
+ arguments: {},
1256
+ },
1257
+ });
1258
+ });
1259
+
1260
+ // Should succeed — acting user comes from request context
1261
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1262
+ expect(responseData.total).toBe(0);
1263
+ expect(mockDrupalAuth.get).toHaveBeenCalledTimes(1);
1264
+ });
1265
+ });
1266
+
1267
+ describe("get_announcement_context", () => {
1268
+ it("should fetch tags and affinity groups without user UUID lookup", async () => {
1269
+ // Two parallel calls only — no user UUID lookup
1270
+ mockDrupalAuth.get.mockResolvedValueOnce({
1271
+ data: [
1272
+ { id: "tag-1", attributes: { name: "gpu" } },
1273
+ { id: "tag-2", attributes: { name: "hpc" } },
1274
+ ],
1275
+ });
1276
+ mockDrupalAuth.get.mockResolvedValueOnce({
1277
+ data: [
1278
+ {
1279
+ id: "group-uuid-1",
1280
+ attributes: {
1281
+ title: "Test Group",
1282
+ field_group_id: 123,
1283
+ field_affinity_group_category: "Research",
1284
+ },
1285
+ },
1286
+ ],
1287
+ });
1288
+
1289
+ const result = await server["handleToolCall"]({
1290
+ method: "tools/call",
1291
+ params: {
1292
+ name: "get_announcement_context",
1293
+ arguments: {},
1294
+ },
1295
+ });
1296
+
1297
+ // Exactly 2 calls — tags + views affinity groups, no user lookup
1298
+ expect(mockDrupalAuth.get).toHaveBeenCalledTimes(2);
1299
+ expect(mockDrupalAuth.get).toHaveBeenCalledWith(
1300
+ "/jsonapi/taxonomy_term/tags?page[limit]=100"
1301
+ );
1302
+ expect(mockDrupalAuth.get).toHaveBeenCalledWith(
1303
+ "/jsonapi/views/mcp_my_affinity_groups/page_1"
1304
+ );
1305
+
1306
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1307
+ expect(responseData.tags).toHaveLength(2);
1308
+ expect(responseData.tags[0].name).toBe("gpu");
1309
+ expect(responseData.tags[1].name).toBe("hpc");
1310
+ expect(responseData.affinity_groups).toHaveLength(1);
1311
+ expect(responseData.affinity_groups[0].name).toBe("Test Group");
1312
+ expect(responseData.affinity_groups[0].id).toBe(123);
1313
+ expect(responseData.affinity_groups[0].uuid).toBe("group-uuid-1");
1314
+ expect(responseData.affinity_groups[0].category).toBe("Research");
1315
+ expect(responseData.is_coordinator).toBe(true);
1316
+ expect(responseData.affiliations).toContain("ACCESS Collaboration");
1317
+ expect(responseData.affiliations).toContain("Community");
1318
+ expect(responseData.where_to_share_options).toHaveLength(4);
1319
+ expect(responseData.guidance).toContain("coordinator");
1320
+ });
1321
+
1322
+ it("should indicate non-coordinator when views returns no affinity groups", async () => {
1323
+ mockDrupalAuth.get.mockResolvedValueOnce({
1324
+ data: [{ id: "tag-1", attributes: { name: "gpu" } }],
1325
+ });
1326
+ mockDrupalAuth.get.mockResolvedValueOnce({
1327
+ data: [],
1328
+ });
1329
+
1330
+ const result = await server["handleToolCall"]({
1331
+ method: "tools/call",
1332
+ params: {
1333
+ name: "get_announcement_context",
1334
+ arguments: {},
1335
+ },
1336
+ });
1337
+
1338
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1339
+ expect(responseData.is_coordinator).toBe(false);
1340
+ expect(responseData.affinity_groups).toHaveLength(0);
1341
+ expect(responseData.guidance).toContain("not a coordinator");
1342
+ });
1343
+
1344
+ it("should fail without acting user", async () => {
1345
+ delete process.env.ACTING_USER;
1346
+
1347
+ const result = await server["handleToolCall"]({
1348
+ method: "tools/call",
1349
+ params: {
1350
+ name: "get_announcement_context",
1351
+ arguments: {},
1352
+ },
1353
+ });
1354
+
1355
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1356
+ expect(responseData.error).toContain("No acting user specified");
1357
+ // Should not have made any API calls
1358
+ expect(mockDrupalAuth.get).not.toHaveBeenCalled();
1359
+ });
1360
+
1361
+ it("should use acting user from request context", async () => {
1362
+ delete process.env.ACTING_USER;
1363
+
1364
+ mockDrupalAuth.get.mockResolvedValueOnce({ data: [] });
1365
+ mockDrupalAuth.get.mockResolvedValueOnce({ data: [] });
1366
+
1367
+ const context: RequestContext = {
1368
+ actingUser: "contextuser@access-ci.org",
1369
+ };
1370
+
1371
+ const result = await requestContextStorage.run(context, async () => {
1372
+ return server["handleToolCall"]({
1373
+ method: "tools/call",
1374
+ params: {
1375
+ name: "get_announcement_context",
1376
+ arguments: {},
1377
+ },
1378
+ });
1379
+ });
1380
+
1381
+ // Should succeed — acting user from request context
1382
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1383
+ expect(responseData.tags).toHaveLength(0);
1384
+ expect(responseData.is_coordinator).toBe(false);
1385
+ expect(mockDrupalAuth.get).toHaveBeenCalledTimes(2);
1386
+ });
1387
+ });
1388
+ });
1389
+
1390
+ describe("Resources", () => {
1391
+ it("should read accessci://announcements resource", async () => {
1392
+ mockHttpClient.get.mockResolvedValue({
1393
+ status: 200,
1394
+ data: [
1395
+ {
1396
+ uuid: "test-uuid-1",
1397
+ title: "Test Announcement",
1398
+ body: "<p>Content</p>",
1399
+ published_date: "2024-03-15",
1400
+ tags: "gpu,hpc",
1401
+ affinity_group: [],
1402
+ },
1403
+ ],
1404
+ });
1405
+
1406
+ const result = await server["handleResourceRead"]({
1407
+ method: "resources/read",
1408
+ params: {
1409
+ uri: "accessci://announcements",
1410
+ },
1411
+ });
1412
+
1413
+ expect(result.contents).toHaveLength(1);
1414
+ expect(result.contents[0].uri).toBe("accessci://announcements");
1415
+ expect(result.contents[0].mimeType).toBe("application/json");
1416
+
1417
+ const data = JSON.parse(result.contents[0].text as string);
1418
+ expect(data).toHaveLength(1);
1419
+ expect(data[0].title).toBe("Test Announcement");
1420
+ });
1421
+
1422
+ it("should handle resource read errors", async () => {
1423
+ mockHttpClient.get.mockResolvedValue({
1424
+ status: 500,
1425
+ statusText: "Internal Server Error",
1426
+ });
1427
+
1428
+ const result = await server["handleResourceRead"]({
1429
+ method: "resources/read",
1430
+ params: {
1431
+ uri: "accessci://announcements",
1432
+ },
1433
+ });
1434
+
1435
+ expect(result.contents[0].text).toContain("Error loading announcements");
1436
+ });
1437
+
1438
+ it("should throw for unknown resource", async () => {
1439
+ await expect(
1440
+ server["handleResourceRead"]({
1441
+ method: "resources/read",
1442
+ params: {
1443
+ uri: "accessci://unknown",
1444
+ },
1445
+ })
1446
+ ).rejects.toThrow("Unknown resource");
1447
+ });
1448
+ });
1449
+
1450
+ describe("Prompts", () => {
1451
+ it("should return create_announcement_guide prompt", async () => {
1452
+ const result = await server["handleGetPrompt"]({
1453
+ params: {
1454
+ name: "create_announcement_guide",
1455
+ arguments: {},
1456
+ },
1457
+ });
1458
+
1459
+ expect(result.description).toBe("Guide for creating an ACCESS announcement");
1460
+ expect(result.messages).toHaveLength(2);
1461
+ expect(result.messages[0].role).toBe("user");
1462
+ expect(result.messages[1].role).toBe("assistant");
1463
+ });
1464
+
1465
+ it("should include topic in create_announcement_guide", async () => {
1466
+ const result = await server["handleGetPrompt"]({
1467
+ params: {
1468
+ name: "create_announcement_guide",
1469
+ arguments: { topic: "GPU availability" },
1470
+ },
1471
+ });
1472
+
1473
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- MCP SDK prompt message content type
1474
+ expect((result.messages[0].content as any).text).toContain("GPU availability");
1475
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- MCP SDK prompt message content type
1476
+ expect((result.messages[1].content as any).text).toContain("GPU availability");
1477
+ });
1478
+
1479
+ it("should return manage_announcements_guide prompt", async () => {
1480
+ const result = await server["handleGetPrompt"]({
1481
+ params: {
1482
+ name: "manage_announcements_guide",
1483
+ arguments: {},
1484
+ },
1485
+ });
1486
+
1487
+ expect(result.description).toBe("Guide for managing existing announcements");
1488
+ expect(result.messages).toHaveLength(2);
1489
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- MCP SDK prompt message content type
1490
+ expect((result.messages[1].content as any).text).toContain("get_my_announcements");
1491
+ });
1492
+
1493
+ it("should throw for unknown prompt", async () => {
1494
+ await expect(
1495
+ server["handleGetPrompt"]({
1496
+ params: {
1497
+ name: "unknown_prompt",
1498
+ arguments: {},
1499
+ },
1500
+ })
1501
+ ).rejects.toThrow("Unknown prompt");
1502
+ });
1503
+ });
1504
+
1505
+ describe("Unknown Tool", () => {
1506
+ it("should return error for unknown tool", async () => {
1507
+ const result = await server["handleToolCall"]({
1508
+ method: "tools/call",
1509
+ params: {
1510
+ name: "unknown_tool",
1511
+ arguments: {},
1512
+ },
1513
+ });
1514
+
1515
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1516
+ expect(responseData.error).toContain("Unknown tool");
1517
+ });
1518
+ });
1519
+ });