@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@access-mcp/announcements",
3
- "version": "0.2.0",
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": "^0.3.3",
29
+ "@access-mcp/shared": "*",
30
30
  "@modelcontextprotocol/sdk": "^0.5.0",
31
31
  "axios": "^1.7.0"
32
32
  },
package/src/index.ts CHANGED
@@ -12,4 +12,4 @@ main().catch((error) => {
12
12
  // Log errors to stderr and exit
13
13
  console.error("Server error:", error);
14
14
  process.exit(1);
15
- });
15
+ });
@@ -1,5 +1,29 @@
1
- import { describe, it, expect } 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";
4
+
5
+ interface TextContent {
6
+ type: "text";
7
+ text: string;
8
+ }
9
+
10
+ interface AnnouncementItem {
11
+ title: string;
12
+ body: string;
13
+ published_date: string;
14
+ tags: string[];
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
+ }
3
27
 
4
28
  /**
5
29
  * Integration tests for AnnouncementsServer
@@ -15,24 +39,25 @@ describe("AnnouncementsServer Integration Tests", () => {
15
39
  describe("get_announcements", () => {
16
40
  it("should fetch real announcements from API", async () => {
17
41
  const result = await server["handleToolCall"]({
42
+ method: "tools/call",
18
43
  params: {
19
44
  name: "search_announcements",
20
45
  arguments: {
21
46
  limit: 5,
22
47
  },
23
48
  },
24
- } as any);
49
+ });
25
50
 
26
51
  expect(result.isError).toBeFalsy();
27
- const responseData = JSON.parse(result.content[0].text);
28
-
52
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
53
+
29
54
  // Check structure
30
55
  expect(responseData).toHaveProperty("total");
31
56
  expect(responseData).toHaveProperty("items");
32
57
 
33
58
  // Items should be an array
34
59
  expect(Array.isArray(responseData.items)).toBe(true);
35
-
60
+
36
61
  // If there are announcements, check their structure
37
62
  if (responseData.items.length > 0) {
38
63
  const firstAnnouncement = responseData.items[0];
@@ -46,6 +71,7 @@ describe("AnnouncementsServer Integration Tests", () => {
46
71
 
47
72
  it("should filter by tags", async () => {
48
73
  const result = await server["handleToolCall"]({
74
+ method: "tools/call",
49
75
  params: {
50
76
  name: "search_announcements",
51
77
  arguments: {
@@ -53,20 +79,19 @@ describe("AnnouncementsServer Integration Tests", () => {
53
79
  limit: 3,
54
80
  },
55
81
  },
56
- } as any);
82
+ });
57
83
 
58
84
  expect(result.isError).toBeFalsy();
59
- const responseData = JSON.parse(result.content[0].text);
85
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
60
86
 
61
87
  expect(responseData).toHaveProperty("items");
62
88
  expect(Array.isArray(responseData.items)).toBe(true);
63
89
 
64
90
  // If maintenance announcements exist, they should contain the tag
65
91
  if (responseData.items.length > 0) {
66
- const hasMaintenanceTag = responseData.items.some((ann: any) =>
67
- ann.tags && ann.tags.some((tag: string) =>
68
- tag.toLowerCase().includes("maintenance")
69
- )
92
+ const hasMaintenanceTag = responseData.items.some(
93
+ (ann: AnnouncementItem) =>
94
+ ann.tags && ann.tags.some((tag: string) => tag.toLowerCase().includes("maintenance"))
70
95
  );
71
96
  // This might not always be true if the API doesn't have maintenance announcements
72
97
  console.log("Found maintenance announcements:", hasMaintenanceTag);
@@ -75,6 +100,7 @@ describe("AnnouncementsServer Integration Tests", () => {
75
100
 
76
101
  it("should handle date range filters", async () => {
77
102
  const result = await server["handleToolCall"]({
103
+ method: "tools/call",
78
104
  params: {
79
105
  name: "search_announcements",
80
106
  arguments: {
@@ -82,10 +108,10 @@ describe("AnnouncementsServer Integration Tests", () => {
82
108
  limit: 10,
83
109
  },
84
110
  },
85
- } as any);
111
+ });
86
112
 
87
113
  expect(result.isError).toBeFalsy();
88
- const responseData = JSON.parse(result.content[0].text);
114
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
89
115
 
90
116
  expect(responseData).toHaveProperty("items");
91
117
 
@@ -94,7 +120,7 @@ describe("AnnouncementsServer Integration Tests", () => {
94
120
  const oneMonthAgo = new Date();
95
121
  oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
96
122
 
97
- responseData.items.forEach((ann: any) => {
123
+ responseData.items.forEach((ann: AnnouncementItem) => {
98
124
  const annDate = new Date(ann.published_date);
99
125
  expect(annDate.getTime()).toBeGreaterThanOrEqual(oneMonthAgo.getTime());
100
126
  });
@@ -105,16 +131,17 @@ describe("AnnouncementsServer Integration Tests", () => {
105
131
  describe("get_recent_announcements", () => {
106
132
  it("should fetch announcements from the past week", async () => {
107
133
  const result = await server["handleToolCall"]({
134
+ method: "tools/call",
108
135
  params: {
109
136
  name: "search_announcements",
110
137
  arguments: {
111
138
  date: "this_week",
112
139
  },
113
140
  },
114
- } as any);
141
+ });
115
142
 
116
143
  expect(result.isError).toBeFalsy();
117
- const responseData = JSON.parse(result.content[0].text);
144
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
118
145
 
119
146
  expect(responseData).toHaveProperty("items");
120
147
 
@@ -123,7 +150,7 @@ describe("AnnouncementsServer Integration Tests", () => {
123
150
  const oneWeekAgo = new Date();
124
151
  oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
125
152
 
126
- responseData.items.forEach((ann: any) => {
153
+ responseData.items.forEach((ann: AnnouncementItem) => {
127
154
  const annDate = new Date(ann.published_date);
128
155
  expect(annDate.getTime()).toBeGreaterThanOrEqual(oneWeekAgo.getTime());
129
156
  });
@@ -132,16 +159,17 @@ describe("AnnouncementsServer Integration Tests", () => {
132
159
 
133
160
  it("should handle today filter", async () => {
134
161
  const result = await server["handleToolCall"]({
162
+ method: "tools/call",
135
163
  params: {
136
164
  name: "search_announcements",
137
165
  arguments: {
138
166
  date: "today",
139
167
  },
140
168
  },
141
- } as any);
169
+ });
142
170
 
143
171
  expect(result.isError).toBeFalsy();
144
- const responseData = JSON.parse(result.content[0].text);
172
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
145
173
 
146
174
  expect(responseData).toHaveProperty("items");
147
175
 
@@ -153,16 +181,17 @@ describe("AnnouncementsServer Integration Tests", () => {
153
181
  describe("get_announcements with limit", () => {
154
182
  it("should respect limit parameter", async () => {
155
183
  const result = await server["handleToolCall"]({
184
+ method: "tools/call",
156
185
  params: {
157
186
  name: "search_announcements",
158
187
  arguments: {
159
188
  limit: 5,
160
189
  },
161
190
  },
162
- } as any);
191
+ });
163
192
 
164
193
  expect(result.isError).toBeFalsy();
165
- const responseData = JSON.parse(result.content[0].text);
194
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
166
195
 
167
196
  // Should return a valid response
168
197
  expect(responseData).toHaveProperty("items");
@@ -173,34 +202,57 @@ describe("AnnouncementsServer Integration Tests", () => {
173
202
  }, 10000);
174
203
  });
175
204
 
205
+ describe("search with query parameter", () => {
206
+ it("should perform full-text search", async () => {
207
+ const result = await server["handleToolCall"]({
208
+ method: "tools/call",
209
+ params: {
210
+ name: "search_announcements",
211
+ arguments: {
212
+ query: "ACCESS",
213
+ limit: 5,
214
+ },
215
+ },
216
+ });
217
+
218
+ expect(result.isError).toBeFalsy();
219
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
220
+
221
+ expect(responseData).toHaveProperty("items");
222
+ expect(Array.isArray(responseData.items)).toBe(true);
223
+ }, 10000);
224
+ });
225
+
176
226
  describe("API Error Handling", () => {
177
227
  it("should handle search with no parameters", async () => {
178
228
  const result = await server["handleToolCall"]({
229
+ method: "tools/call",
179
230
  params: {
180
231
  name: "search_announcements",
181
232
  arguments: {},
182
233
  },
183
- } as any);
234
+ });
184
235
 
185
236
  // Should return default results
186
237
  expect(result).toBeDefined();
187
238
  expect(result.content).toBeDefined();
188
- const responseData = JSON.parse(result.content[0].text);
239
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
189
240
  expect(responseData).toHaveProperty("items");
190
241
  }, 10000);
191
242
 
192
243
  it("should handle empty results", async () => {
193
244
  const result = await server["handleToolCall"]({
245
+ method: "tools/call",
194
246
  params: {
195
247
  name: "search_announcements",
196
248
  arguments: {
197
249
  tags: "nonexistent-tag-xyz-123-456",
198
250
  },
199
251
  },
200
- } as any);
252
+ });
201
253
 
202
254
  expect(result.isError).toBeFalsy();
203
- const responseData = JSON.parse(result.content[0].text);
255
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
204
256
 
205
257
  // May have 0 or few results
206
258
  expect(responseData).toHaveProperty("total");
@@ -208,4 +260,281 @@ describe("AnnouncementsServer Integration Tests", () => {
208
260
  expect(Array.isArray(responseData.items)).toBe(true);
209
261
  }, 10000);
210
262
  });
211
- });
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
+ });