@access-mcp/announcements 0.3.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/dist/server.d.ts +19 -5
- package/dist/server.js +198 -158
- package/package.json +2 -2
- package/src/index.ts +1 -1
- package/src/server.integration.test.ts +296 -8
- package/src/server.test.ts +334 -109
- package/src/server.ts +361 -241
- package/vitest.integration.config.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@access-mcp/announcements",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "MCP server for ACCESS Support Announcements API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"author": "ACCESS-CI",
|
|
27
27
|
"license": "MIT",
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@access-mcp/shared": "
|
|
29
|
+
"@access-mcp/shared": "*",
|
|
30
30
|
"@modelcontextprotocol/sdk": "^0.5.0",
|
|
31
31
|
"axios": "^1.7.0"
|
|
32
32
|
},
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from "vitest";
|
|
1
|
+
import { describe, it, expect, beforeEach, afterAll } from "vitest";
|
|
2
2
|
import { AnnouncementsServer } from "./server.js";
|
|
3
|
+
import { requestContextStorage, RequestContext } from "@access-mcp/shared";
|
|
3
4
|
|
|
4
5
|
interface TextContent {
|
|
5
6
|
type: "text";
|
|
@@ -13,6 +14,17 @@ interface AnnouncementItem {
|
|
|
13
14
|
tags: string[];
|
|
14
15
|
}
|
|
15
16
|
|
|
17
|
+
interface MyAnnouncementItem {
|
|
18
|
+
uuid: string;
|
|
19
|
+
nid: number;
|
|
20
|
+
title: string;
|
|
21
|
+
status: string;
|
|
22
|
+
created: string;
|
|
23
|
+
published_date: string;
|
|
24
|
+
summary: string;
|
|
25
|
+
edit_url: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
/**
|
|
17
29
|
* Integration tests for AnnouncementsServer
|
|
18
30
|
* These tests make actual API calls to the ACCESS Support API
|
|
@@ -38,14 +50,14 @@ describe("AnnouncementsServer Integration Tests", () => {
|
|
|
38
50
|
|
|
39
51
|
expect(result.isError).toBeFalsy();
|
|
40
52
|
const responseData = JSON.parse((result.content[0] as TextContent).text);
|
|
41
|
-
|
|
53
|
+
|
|
42
54
|
// Check structure
|
|
43
55
|
expect(responseData).toHaveProperty("total");
|
|
44
56
|
expect(responseData).toHaveProperty("items");
|
|
45
57
|
|
|
46
58
|
// Items should be an array
|
|
47
59
|
expect(Array.isArray(responseData.items)).toBe(true);
|
|
48
|
-
|
|
60
|
+
|
|
49
61
|
// If there are announcements, check their structure
|
|
50
62
|
if (responseData.items.length > 0) {
|
|
51
63
|
const firstAnnouncement = responseData.items[0];
|
|
@@ -77,10 +89,9 @@ describe("AnnouncementsServer Integration Tests", () => {
|
|
|
77
89
|
|
|
78
90
|
// If maintenance announcements exist, they should contain the tag
|
|
79
91
|
if (responseData.items.length > 0) {
|
|
80
|
-
const hasMaintenanceTag = responseData.items.some(
|
|
81
|
-
ann
|
|
82
|
-
tag.toLowerCase().includes("maintenance")
|
|
83
|
-
)
|
|
92
|
+
const hasMaintenanceTag = responseData.items.some(
|
|
93
|
+
(ann: AnnouncementItem) =>
|
|
94
|
+
ann.tags && ann.tags.some((tag: string) => tag.toLowerCase().includes("maintenance"))
|
|
84
95
|
);
|
|
85
96
|
// This might not always be true if the API doesn't have maintenance announcements
|
|
86
97
|
console.log("Found maintenance announcements:", hasMaintenanceTag);
|
|
@@ -249,4 +260,281 @@ describe("AnnouncementsServer Integration Tests", () => {
|
|
|
249
260
|
expect(Array.isArray(responseData.items)).toBe(true);
|
|
250
261
|
}, 10000);
|
|
251
262
|
});
|
|
252
|
-
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* E2E tests for authenticated CRUD operations.
|
|
267
|
+
*
|
|
268
|
+
* Requires env vars: DRUPAL_API_URL, DRUPAL_USERNAME, DRUPAL_PASSWORD, ACTING_USER
|
|
269
|
+
* Tests run against a live Drupal instance (e.g., accessmatch.ddev.site).
|
|
270
|
+
* Skipped if DRUPAL_USERNAME is not set.
|
|
271
|
+
*/
|
|
272
|
+
const hasDrupalCreds = !!process.env.DRUPAL_USERNAME && !!process.env.DRUPAL_PASSWORD;
|
|
273
|
+
|
|
274
|
+
describe.skipIf(!hasDrupalCreds)("Authenticated CRUD E2E Tests", () => {
|
|
275
|
+
let server: AnnouncementsServer;
|
|
276
|
+
const createdUuids: string[] = [];
|
|
277
|
+
|
|
278
|
+
beforeEach(() => {
|
|
279
|
+
server = new AnnouncementsServer();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Clean up any announcements created during tests
|
|
283
|
+
afterAll(async () => {
|
|
284
|
+
if (createdUuids.length === 0) return;
|
|
285
|
+
const cleanupServer = new AnnouncementsServer();
|
|
286
|
+
for (const uuid of createdUuids) {
|
|
287
|
+
try {
|
|
288
|
+
await cleanupServer["handleToolCall"]({
|
|
289
|
+
method: "tools/call",
|
|
290
|
+
params: {
|
|
291
|
+
name: "delete_announcement",
|
|
292
|
+
arguments: { uuid, confirmed: true },
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
} catch {
|
|
296
|
+
// Best effort cleanup
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe("get_announcement_context", () => {
|
|
302
|
+
it("should return tags and coordinator status via views endpoint", async () => {
|
|
303
|
+
const result = await server["handleToolCall"]({
|
|
304
|
+
method: "tools/call",
|
|
305
|
+
params: {
|
|
306
|
+
name: "get_announcement_context",
|
|
307
|
+
arguments: {},
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const responseData = JSON.parse((result.content[0] as TextContent).text);
|
|
312
|
+
|
|
313
|
+
// Should have tags array
|
|
314
|
+
expect(responseData).toHaveProperty("tags");
|
|
315
|
+
expect(Array.isArray(responseData.tags)).toBe(true);
|
|
316
|
+
if (responseData.tags.length > 0) {
|
|
317
|
+
expect(responseData.tags[0]).toHaveProperty("name");
|
|
318
|
+
expect(responseData.tags[0]).toHaveProperty("uuid");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Should have affinity groups (may be empty if user is not a coordinator)
|
|
322
|
+
expect(responseData).toHaveProperty("affinity_groups");
|
|
323
|
+
expect(Array.isArray(responseData.affinity_groups)).toBe(true);
|
|
324
|
+
|
|
325
|
+
// Should have is_coordinator boolean
|
|
326
|
+
expect(responseData).toHaveProperty("is_coordinator");
|
|
327
|
+
expect(typeof responseData.is_coordinator).toBe("boolean");
|
|
328
|
+
expect(responseData.is_coordinator).toBe(responseData.affinity_groups.length > 0);
|
|
329
|
+
|
|
330
|
+
// Should have static options
|
|
331
|
+
expect(responseData.affiliations).toContain("ACCESS Collaboration");
|
|
332
|
+
expect(responseData.affiliations).toContain("Community");
|
|
333
|
+
expect(responseData.where_to_share_options).toHaveLength(4);
|
|
334
|
+
}, 15000);
|
|
335
|
+
|
|
336
|
+
it("should work with request context acting user", async () => {
|
|
337
|
+
const savedActingUser = process.env.ACTING_USER;
|
|
338
|
+
delete process.env.ACTING_USER;
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
const context: RequestContext = {
|
|
342
|
+
actingUser: savedActingUser || "apasquale@access-ci.org",
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const result = await requestContextStorage.run(context, async () => {
|
|
346
|
+
return server["handleToolCall"]({
|
|
347
|
+
method: "tools/call",
|
|
348
|
+
params: {
|
|
349
|
+
name: "get_announcement_context",
|
|
350
|
+
arguments: {},
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const responseData = JSON.parse((result.content[0] as TextContent).text);
|
|
356
|
+
expect(responseData).toHaveProperty("tags");
|
|
357
|
+
expect(responseData).toHaveProperty("is_coordinator");
|
|
358
|
+
} finally {
|
|
359
|
+
if (savedActingUser) process.env.ACTING_USER = savedActingUser;
|
|
360
|
+
}
|
|
361
|
+
}, 15000);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe("create, get_my, update, delete announcement", () => {
|
|
365
|
+
it("should create an announcement and find it via get_my_announcements", async () => {
|
|
366
|
+
// 1. Create announcement
|
|
367
|
+
const createResult = await server["handleToolCall"]({
|
|
368
|
+
method: "tools/call",
|
|
369
|
+
params: {
|
|
370
|
+
name: "create_announcement",
|
|
371
|
+
arguments: {
|
|
372
|
+
title: "E2E Test Announcement",
|
|
373
|
+
body: "<p>This is an automated e2e test announcement.</p>",
|
|
374
|
+
summary: "E2E test summary",
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const createData = JSON.parse((createResult.content[0] as TextContent).text);
|
|
380
|
+
expect(createData.success).toBe(true);
|
|
381
|
+
expect(createData.uuid).toBeTruthy();
|
|
382
|
+
expect(createData.title).toBe("E2E Test Announcement");
|
|
383
|
+
expect(createData.edit_url).toContain("/node/");
|
|
384
|
+
expect(createData.edit_url).toContain("/edit");
|
|
385
|
+
|
|
386
|
+
createdUuids.push(createData.uuid);
|
|
387
|
+
|
|
388
|
+
// 2. Verify it appears in get_my_announcements
|
|
389
|
+
const myResult = await server["handleToolCall"]({
|
|
390
|
+
method: "tools/call",
|
|
391
|
+
params: {
|
|
392
|
+
name: "get_my_announcements",
|
|
393
|
+
arguments: { limit: 50 },
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
const myData = JSON.parse((myResult.content[0] as TextContent).text);
|
|
398
|
+
expect(myData.total).toBeGreaterThanOrEqual(1);
|
|
399
|
+
|
|
400
|
+
const found = myData.items.find(
|
|
401
|
+
(item: MyAnnouncementItem) => item.uuid === createData.uuid
|
|
402
|
+
);
|
|
403
|
+
expect(found).toBeTruthy();
|
|
404
|
+
expect(found.title).toBe("E2E Test Announcement");
|
|
405
|
+
expect(found.status).toBe("draft");
|
|
406
|
+
expect(found.summary).toBe("E2E test summary");
|
|
407
|
+
expect(found.uuid).toBe(createData.uuid);
|
|
408
|
+
expect(found.edit_url).toContain("/edit");
|
|
409
|
+
}, 30000);
|
|
410
|
+
|
|
411
|
+
it("should update an existing announcement", async () => {
|
|
412
|
+
// Create one to update
|
|
413
|
+
const createResult = await server["handleToolCall"]({
|
|
414
|
+
method: "tools/call",
|
|
415
|
+
params: {
|
|
416
|
+
name: "create_announcement",
|
|
417
|
+
arguments: {
|
|
418
|
+
title: "E2E Update Test",
|
|
419
|
+
body: "<p>Original body</p>",
|
|
420
|
+
summary: "Original summary",
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const createData = JSON.parse((createResult.content[0] as TextContent).text);
|
|
426
|
+
expect(createData.success).toBe(true);
|
|
427
|
+
createdUuids.push(createData.uuid);
|
|
428
|
+
|
|
429
|
+
// Update it
|
|
430
|
+
const updateResult = await server["handleToolCall"]({
|
|
431
|
+
method: "tools/call",
|
|
432
|
+
params: {
|
|
433
|
+
name: "update_announcement",
|
|
434
|
+
arguments: {
|
|
435
|
+
uuid: createData.uuid,
|
|
436
|
+
title: "E2E Update Test - Modified",
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
const updateData = JSON.parse((updateResult.content[0] as TextContent).text);
|
|
442
|
+
expect(updateData.success).toBe(true);
|
|
443
|
+
expect(updateData.title).toBe("E2E Update Test - Modified");
|
|
444
|
+
}, 30000);
|
|
445
|
+
|
|
446
|
+
it("should delete an announcement", async () => {
|
|
447
|
+
// Create one to delete
|
|
448
|
+
const createResult = await server["handleToolCall"]({
|
|
449
|
+
method: "tools/call",
|
|
450
|
+
params: {
|
|
451
|
+
name: "create_announcement",
|
|
452
|
+
arguments: {
|
|
453
|
+
title: "E2E Delete Test",
|
|
454
|
+
body: "<p>To be deleted</p>",
|
|
455
|
+
summary: "Will be deleted",
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const createData = JSON.parse((createResult.content[0] as TextContent).text);
|
|
461
|
+
expect(createData.success).toBe(true);
|
|
462
|
+
const uuid = createData.uuid;
|
|
463
|
+
|
|
464
|
+
// Delete it
|
|
465
|
+
const deleteResult = await server["handleToolCall"]({
|
|
466
|
+
method: "tools/call",
|
|
467
|
+
params: {
|
|
468
|
+
name: "delete_announcement",
|
|
469
|
+
arguments: { uuid, confirmed: true },
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
const deleteData = JSON.parse((deleteResult.content[0] as TextContent).text);
|
|
474
|
+
expect(deleteData.success).toBe(true);
|
|
475
|
+
expect(deleteData.uuid).toBe(uuid);
|
|
476
|
+
|
|
477
|
+
// Remove from cleanup list since we already deleted it
|
|
478
|
+
const idx = createdUuids.indexOf(uuid);
|
|
479
|
+
if (idx !== -1) createdUuids.splice(idx, 1);
|
|
480
|
+
|
|
481
|
+
// Verify it's gone from get_my_announcements
|
|
482
|
+
const myResult = await server["handleToolCall"]({
|
|
483
|
+
method: "tools/call",
|
|
484
|
+
params: {
|
|
485
|
+
name: "get_my_announcements",
|
|
486
|
+
arguments: {},
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
const myData = JSON.parse((myResult.content[0] as TextContent).text);
|
|
491
|
+
const found = myData.items.find(
|
|
492
|
+
(item: MyAnnouncementItem) => item.uuid === uuid
|
|
493
|
+
);
|
|
494
|
+
expect(found).toBeUndefined();
|
|
495
|
+
}, 30000);
|
|
496
|
+
|
|
497
|
+
it("should reject delete without confirmation", async () => {
|
|
498
|
+
const result = await server["handleToolCall"]({
|
|
499
|
+
method: "tools/call",
|
|
500
|
+
params: {
|
|
501
|
+
name: "delete_announcement",
|
|
502
|
+
arguments: {
|
|
503
|
+
uuid: "some-uuid",
|
|
504
|
+
confirmed: false,
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
const responseData = JSON.parse((result.content[0] as TextContent).text);
|
|
510
|
+
expect(responseData.error).toContain("explicit confirmation");
|
|
511
|
+
}, 10000);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
describe("get_my_announcements", () => {
|
|
515
|
+
it("should return proper structure from views endpoint", async () => {
|
|
516
|
+
const result = await server["handleToolCall"]({
|
|
517
|
+
method: "tools/call",
|
|
518
|
+
params: {
|
|
519
|
+
name: "get_my_announcements",
|
|
520
|
+
arguments: {},
|
|
521
|
+
},
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
const responseData = JSON.parse((result.content[0] as TextContent).text);
|
|
525
|
+
expect(responseData).toHaveProperty("total");
|
|
526
|
+
expect(responseData).toHaveProperty("items");
|
|
527
|
+
expect(Array.isArray(responseData.items)).toBe(true);
|
|
528
|
+
|
|
529
|
+
// If there are items, check structure
|
|
530
|
+
if (responseData.items.length > 0) {
|
|
531
|
+
const item = responseData.items[0];
|
|
532
|
+
expect(item).toHaveProperty("uuid");
|
|
533
|
+
expect(item).toHaveProperty("title");
|
|
534
|
+
expect(item).toHaveProperty("status");
|
|
535
|
+
expect(["draft", "published"]).toContain(item.status);
|
|
536
|
+
expect(item).toHaveProperty("edit_url");
|
|
537
|
+
}
|
|
538
|
+
}, 15000);
|
|
539
|
+
});
|
|
540
|
+
});
|