@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.
- package/dist/server.d.ts +36 -9
- package/dist/server.js +371 -217
- package/package.json +3 -3
- package/src/index.ts +1 -1
- package/src/server.integration.test.ts +296 -8
- package/src/server.test.ts +443 -119
- package/src/server.ts +529 -275
- package/vitest.integration.config.ts +1 -1
package/src/server.test.ts
CHANGED
|
@@ -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
|
-
{
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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]=
|
|
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
|
|
671
|
-
delete process.env.
|
|
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: "
|
|
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
|
-
|
|
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.
|
|
686
|
-
expect(responseData.error).toContain("No acting user specified");
|
|
728
|
+
expect(responseData.success).toBe(true);
|
|
687
729
|
});
|
|
688
730
|
|
|
689
|
-
it("should
|
|
690
|
-
|
|
691
|
-
|
|
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
|
-
//
|
|
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
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
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
|
|
1079
|
-
expect(mockDrupalAuth.get).
|
|
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
|
-
|
|
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
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
1104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1131
|
-
expect(responseData.
|
|
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
|
|
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
|
+
});
|