@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.
- package/README.md +106 -135
- package/dist/server.d.ts +85 -90
- package/dist/server.js +905 -152
- package/package.json +2 -2
- package/src/index.ts +1 -1
- package/src/server.integration.test.ts +355 -26
- package/src/server.test.ts +1194 -30
- package/src/server.ts +1156 -58
- package/vitest.integration.config.ts +1 -1
package/src/server.test.ts
CHANGED
|
@@ -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:
|
|
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
|
-
}
|
|
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
|
-
}
|
|
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
|
-
}
|
|
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
|
-
}
|
|
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
|
-
}
|
|
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
|
-
}
|
|
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
|
-
}
|
|
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
|
-
}
|
|
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
|
-
}
|
|
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
|
-
}
|
|
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
|
-
{
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
}
|
|
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
|
-
}
|
|
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
|
+
});
|