@access-mcp/announcements 0.2.0 → 0.3.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/src/server.ts CHANGED
@@ -1,6 +1,22 @@
1
- import { BaseAccessServer } from "@access-mcp/shared";
1
+ import { BaseAccessServer, Tool, Resource, CallToolResult, DrupalAuthProvider } from "@access-mcp/shared";
2
+ import { CallToolRequest, ReadResourceRequest, ReadResourceResult, GetPromptResult, Prompt } from "@modelcontextprotocol/sdk/types.js";
3
+ import { createRequire } from "module";
4
+ const require = createRequire(import.meta.url);
5
+ const { version } = require("../package.json");
6
+
7
+ // ============================================================================
8
+ // Types
9
+ // ============================================================================
10
+
11
+ interface SearchAnnouncementsArgs {
12
+ query?: string;
13
+ tags?: string;
14
+ date?: string;
15
+ limit?: number;
16
+ }
2
17
 
3
18
  interface AnnouncementFilters {
19
+ query?: string;
4
20
  tags?: string;
5
21
  ag?: string;
6
22
  affiliation?: string;
@@ -8,39 +24,109 @@ interface AnnouncementFilters {
8
24
  relative_end_date?: string;
9
25
  start_date?: string;
10
26
  end_date?: string;
27
+ date?: string;
11
28
  limit?: number;
12
29
  }
13
30
 
14
31
  interface Announcement {
32
+ uuid?: string; // Added in API v2.2 update
15
33
  title: string;
16
34
  body: string;
17
35
  published_date: string;
18
- affinity_group: string[];
19
- tags: string[];
36
+ affinity_group: string | string[];
37
+ tags: string | string[]; // API returns comma-separated string, we convert to array
20
38
  affiliation: string;
21
39
  summary?: string;
22
40
  }
23
41
 
42
+ interface CreateAnnouncementArgs {
43
+ title: string;
44
+ body: string;
45
+ summary?: string;
46
+ published_date?: string;
47
+ tags?: string[];
48
+ affiliation?: string;
49
+ affinity_group?: string;
50
+ external_link?: { uri: string; title?: string };
51
+ where_to_share?: string[];
52
+ }
53
+
54
+ interface UpdateAnnouncementArgs {
55
+ uuid: string;
56
+ title?: string;
57
+ body?: string;
58
+ summary?: string;
59
+ published_date?: string;
60
+ tags?: string[];
61
+ affinity_group?: string;
62
+ external_link?: { uri: string; title?: string };
63
+ where_to_share?: string[];
64
+ }
65
+
66
+ interface DeleteAnnouncementArgs {
67
+ uuid: string;
68
+ confirmed: boolean;
69
+ }
70
+
71
+ interface GetMyAnnouncementsArgs {
72
+ limit?: number;
73
+ }
74
+
75
+ // ============================================================================
76
+ // Server
77
+ // ============================================================================
78
+
24
79
  export class AnnouncementsServer extends BaseAccessServer {
80
+ private drupalAuth?: DrupalAuthProvider;
81
+ private tagCache: Map<string, string> = new Map(); // name -> uuid
82
+ private tagCacheExpiry?: Date;
83
+ private static TAG_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
84
+
25
85
  constructor() {
26
- super("access-announcements", "0.1.0", "https://support.access-ci.org");
86
+ super("access-announcements", version, "https://support.access-ci.org");
87
+ }
88
+
89
+ /**
90
+ * Get or create the Drupal auth provider for JSON:API write operations.
91
+ * Requires DRUPAL_API_URL, DRUPAL_USERNAME, and DRUPAL_PASSWORD env vars.
92
+ */
93
+ private getDrupalAuth(): DrupalAuthProvider {
94
+ if (!this.drupalAuth) {
95
+ const baseUrl = process.env.DRUPAL_API_URL;
96
+ const username = process.env.DRUPAL_USERNAME;
97
+ const password = process.env.DRUPAL_PASSWORD;
98
+
99
+ if (!baseUrl || !username || !password) {
100
+ throw new Error(
101
+ "CRUD operations require DRUPAL_API_URL, DRUPAL_USERNAME, and DRUPAL_PASSWORD environment variables"
102
+ );
103
+ }
104
+
105
+ this.drupalAuth = new DrupalAuthProvider(baseUrl, username, password);
106
+ }
107
+ return this.drupalAuth;
27
108
  }
28
109
 
29
- protected getTools() {
110
+ protected getTools(): Tool[] {
30
111
  return [
112
+ // Read operations (existing)
31
113
  {
32
114
  name: "search_announcements",
33
- description: "Search ACCESS announcements (news, updates, notifications). Returns {total, items}.",
115
+ description: "Search ACCESS announcements (news, updates, notifications). Read-only view of public announcements. For managing your own announcements (update/delete), use get_my_announcements which returns UUIDs. Returns {total, items: [{title, summary, body, published_date, tags, affiliation, affinity_group}]}.",
34
116
  inputSchema: {
35
117
  type: "object",
36
118
  properties: {
119
+ query: {
120
+ type: "string",
121
+ description: "Full-text search across title, body, and summary"
122
+ },
37
123
  tags: {
38
124
  type: "string",
39
125
  description: "Filter by topics: gpu, ml, hpc"
40
126
  },
41
127
  date: {
42
128
  type: "string",
43
- description: "Time period filter",
129
+ description: "Time period filter: today, this_week (last 7 days), this_month (last 30 days), past (last year)",
44
130
  enum: ["today", "this_week", "this_month", "past"]
45
131
  },
46
132
  limit: {
@@ -50,11 +136,249 @@ export class AnnouncementsServer extends BaseAccessServer {
50
136
  }
51
137
  }
52
138
  }
139
+ },
140
+ // CRUD operations (new)
141
+ {
142
+ name: "create_announcement",
143
+ description: `Create a new ACCESS announcement (saved as draft for staff review).
144
+
145
+ BEFORE CALLING:
146
+ 1. Call get_announcement_context first to get tags, check coordinator status, and tailor the conversation.
147
+ 2. Gather information - either conversationally OR by parsing pasted content:
148
+ - title (required): Clear, specific headline
149
+ - body (required): Full content with details, dates, links. HTML supported.
150
+ - summary (required): 1-2 sentence teaser for listings
151
+ - tags (recommended): 1-6 tags help users find it
152
+ - affiliation (optional): "ACCESS Collaboration" or "Community" (default)
153
+ - external_link (if relevant): URL and link text for external references
154
+ 3. IF user is a coordinator (check get_announcement_context response):
155
+ - affinity_group: Ask which group to associate with
156
+ - where_to_share: Ask preferences (defaults: Announcements page + Bi-Weekly Digest)
157
+
158
+ WORKFLOW:
159
+ If user pastes content (event description, draft text, etc.):
160
+ 1. Call get_announcement_context
161
+ 2. Parse the pasted content to extract title, body, dates, links, etc.
162
+ 3. Match content to available tags from context
163
+ 4. Ask only about missing/unclear fields
164
+ 5. If coordinator: ask about affinity group and where to share
165
+ 6. Show preview and get confirmation
166
+ 7. Create the announcement
167
+
168
+ If user describes what they want conversationally:
169
+ 1. Call get_announcement_context
170
+ 2. Guide them through providing title, body, summary
171
+ 3. Suggest relevant tags from context
172
+ 4. If coordinator: ask about affinity group and where to share
173
+ 5. Show preview and get confirmation
174
+ 6. Create the announcement
175
+
176
+ PREVIEW FORMAT (show before creating):
177
+ ---
178
+ **Title:** [title]
179
+ **Summary:** [summary or "None provided"]
180
+ **Body:**
181
+ [body content, truncate if very long]
182
+
183
+ **Tags:** [tag1, tag2, ...] or "None"
184
+ **Affiliation:** [affiliation or "Community (default)"]
185
+ **External Link:** [link text](url) or omit if none
186
+ [If coordinator:]
187
+ **Affinity Group:** [group name] or "None"
188
+ **Share to:** [list of selected options]
189
+ ---
190
+ Ask "Does this look correct?" before creating.
191
+
192
+ Returns: {success, uuid, title, edit_url}
193
+ ALWAYS display the edit_url to the user so they can review their draft in Drupal.`,
194
+ inputSchema: {
195
+ type: "object",
196
+ properties: {
197
+ title: {
198
+ type: "string",
199
+ description: "Announcement title - clear and specific, under 100 characters"
200
+ },
201
+ body: {
202
+ type: "string",
203
+ description: "Full announcement content. HTML allowed (basic_html format: <p>, <a>, <strong>, <em>, <ul>, <li>)"
204
+ },
205
+ summary: {
206
+ type: "string",
207
+ description: "Brief teaser (1-2 sentences) shown in announcement listings. Required."
208
+ },
209
+ published_date: {
210
+ type: "string",
211
+ description: "When to display (YYYY-MM-DD). Defaults to today."
212
+ },
213
+ tags: {
214
+ type: "array",
215
+ items: { type: "string" },
216
+ description: "Tag names (1-6 required for publication). Use list_available_tags to find valid tags."
217
+ },
218
+ affiliation: {
219
+ type: "string",
220
+ description: "Source of announcement",
221
+ enum: ["ACCESS Collaboration", "Community"]
222
+ },
223
+ affinity_group: {
224
+ type: "string",
225
+ description: "Affinity group name or UUID. User must be coordinator of the group. Use list_affinity_groups to find valid groups."
226
+ },
227
+ external_link: {
228
+ type: "object",
229
+ description: "External link related to this announcement",
230
+ properties: {
231
+ uri: { type: "string", description: "URL (must include https://)" },
232
+ title: { type: "string", description: "Link text to display" }
233
+ },
234
+ required: ["uri"]
235
+ },
236
+ where_to_share: {
237
+ type: "array",
238
+ items: { type: "string" },
239
+ description: "Where to share the announcement. Defaults to 'Announcements page' + 'Bi-Weekly Digest'. Options: 'Announcements page', 'Bi-Weekly Digest', 'Affinity Group page', 'Email to Affinity Group'"
240
+ }
241
+ },
242
+ required: ["title", "body", "summary"]
243
+ }
244
+ },
245
+ {
246
+ name: "update_announcement",
247
+ description: `Update an existing announcement. User must own the announcement.
248
+
249
+ BEFORE CALLING:
250
+ 1. Use get_my_announcements to find the announcement UUID
251
+ 2. Confirm which fields the user wants to change
252
+ 3. Only include fields that are changing
253
+ 4. Show a preview of the changes and ask for confirmation before updating
254
+
255
+ Returns: {success, uuid, title, edit_url}
256
+ ALWAYS display the edit_url to the user so they can review changes in Drupal.`,
257
+ inputSchema: {
258
+ type: "object",
259
+ properties: {
260
+ uuid: {
261
+ type: "string",
262
+ description: "UUID of the announcement (get from get_my_announcements)"
263
+ },
264
+ title: {
265
+ type: "string",
266
+ description: "New title (only if changing)"
267
+ },
268
+ body: {
269
+ type: "string",
270
+ description: "New body content (only if changing)"
271
+ },
272
+ summary: {
273
+ type: "string",
274
+ description: "New summary (only if changing)"
275
+ },
276
+ published_date: {
277
+ type: "string",
278
+ description: "New publication date YYYY-MM-DD (only if changing)"
279
+ },
280
+ tags: {
281
+ type: "array",
282
+ items: { type: "string" },
283
+ description: "New tags - replaces ALL existing tags (only if changing)"
284
+ },
285
+ affinity_group: {
286
+ type: "string",
287
+ description: "Affinity group name or UUID. User must be coordinator."
288
+ },
289
+ external_link: {
290
+ type: "object",
291
+ description: "External link related to this announcement",
292
+ properties: {
293
+ uri: { type: "string", description: "URL (must include https://)" },
294
+ title: { type: "string", description: "Link text to display" }
295
+ },
296
+ required: ["uri"]
297
+ },
298
+ where_to_share: {
299
+ type: "array",
300
+ items: { type: "string" },
301
+ description: "Where to share the announcement. Options: 'Announcements page', 'Bi-Weekly Digest', 'Affinity Group page', 'Email to Affinity Group'"
302
+ }
303
+ },
304
+ required: ["uuid"]
305
+ }
306
+ },
307
+ {
308
+ name: "delete_announcement",
309
+ description: `Permanently delete an announcement. User must own the announcement.
310
+
311
+ CRITICAL: NEVER delete without explicit user confirmation for EACH announcement.
312
+
313
+ BEFORE CALLING (required for EVERY delete):
314
+ 1. Use get_my_announcements to find the announcement
315
+ 2. Show the user: "Delete '[title]' (status: [status])? This cannot be undone."
316
+ 3. For published announcements, add warning: "⚠️ This is currently visible to the public."
317
+ 4. Wait for explicit "yes" or confirmation - general statements like "sure" or "go ahead" are NOT sufficient
318
+ 5. Only proceed after receiving clear confirmation for THIS SPECIFIC announcement
319
+
320
+ BULK DELETES: When user asks to delete multiple announcements, confirm EACH ONE individually.
321
+ Do NOT batch delete based on general consent. Each deletion requires its own confirmation prompt.
322
+
323
+ Returns: {success, uuid}`,
324
+ inputSchema: {
325
+ type: "object",
326
+ properties: {
327
+ uuid: {
328
+ type: "string",
329
+ description: "UUID of the announcement to delete"
330
+ },
331
+ confirmed: {
332
+ type: "boolean",
333
+ description: "Set to true only after user has explicitly confirmed deletion of THIS specific announcement. Required."
334
+ }
335
+ },
336
+ required: ["uuid", "confirmed"]
337
+ }
338
+ },
339
+ {
340
+ name: "get_my_announcements",
341
+ description: `List all announcements created by the authenticated user.
342
+
343
+ Returns announcements with: uuid, nid, title, status (draft/published), created date, published_date, summary, edit_url
344
+
345
+ Use this to:
346
+ - Find announcement UUIDs for update/delete operations
347
+ - Check status of submitted announcements
348
+ - Review what the user has created
349
+ - Get edit_url links for users to review in Drupal`,
350
+ inputSchema: {
351
+ type: "object",
352
+ properties: {
353
+ limit: {
354
+ type: "number",
355
+ description: "Max results (default: 25)",
356
+ default: 25
357
+ }
358
+ }
359
+ }
360
+ },
361
+ {
362
+ name: "get_announcement_context",
363
+ description: `Get user context and options BEFORE creating an announcement. Call this first.
364
+
365
+ Returns:
366
+ - tags: Available tags for announcements [{name, uuid}]
367
+ - affinity_groups: Groups the user coordinates (empty if not a coordinator)
368
+ - is_coordinator: Boolean - if true, ask about affinity_group and where_to_share
369
+ - affiliations: Available affiliation options
370
+ - where_to_share_options: Available sharing options (only relevant for coordinators)
371
+
372
+ Use this to tailor the announcement creation conversation based on the user's role.`,
373
+ inputSchema: {
374
+ type: "object",
375
+ properties: {}
376
+ }
53
377
  }
54
378
  ];
55
379
  }
56
380
 
57
- protected getResources() {
381
+ protected getResources(): Resource[] {
58
382
  return [
59
383
  {
60
384
  uri: "accessci://announcements",
@@ -65,22 +389,166 @@ export class AnnouncementsServer extends BaseAccessServer {
65
389
  ];
66
390
  }
67
391
 
68
- async handleToolCall(request: any) {
392
+ protected getPrompts(): Prompt[] {
393
+ return [
394
+ {
395
+ name: "create_announcement_guide",
396
+ description: "Guide the user through creating a new ACCESS announcement step by step",
397
+ arguments: [
398
+ {
399
+ name: "topic",
400
+ description: "Brief description of what the announcement is about (optional, helps tailor guidance)",
401
+ required: false,
402
+ },
403
+ ],
404
+ },
405
+ {
406
+ name: "manage_announcements_guide",
407
+ description: "Help the user view, update, or delete their existing announcements",
408
+ arguments: [],
409
+ },
410
+ ];
411
+ }
412
+
413
+ protected async handleGetPrompt(request: { params: { name: string; arguments?: Record<string, string> } }): Promise<GetPromptResult> {
414
+ const { name, arguments: args = {} } = request.params;
415
+
416
+ if (name === "create_announcement_guide") {
417
+ const topic = args.topic || "";
418
+
419
+ return {
420
+ description: "Guide for creating an ACCESS announcement",
421
+ messages: [
422
+ {
423
+ role: "user",
424
+ content: {
425
+ type: "text",
426
+ text: topic
427
+ ? `I want to create an announcement about: ${topic}`
428
+ : "I want to create a new announcement",
429
+ },
430
+ },
431
+ {
432
+ role: "assistant",
433
+ content: {
434
+ type: "text",
435
+ text: `I'll help you create an ACCESS announcement. Let me first check your available options and coordinator status.
436
+
437
+ First, I'll call \`get_announcement_context\` to see what tags are available and whether you coordinate any affinity groups.
438
+
439
+ **Required Information:**
440
+
441
+ 1. **Title** - A clear, concise title (under 100 characters)
442
+
443
+ 2. **Body** - The main content (HTML supported)
444
+
445
+ **Recommended:**
446
+
447
+ 3. **Summary** - Brief teaser (1-2 sentences) for listings
448
+
449
+ 4. **Tags** - Help users find your announcement (1-6 recommended)
450
+
451
+ **Optional:**
452
+
453
+ 5. **Affiliation** - "ACCESS Collaboration" or "Community" (default)
454
+
455
+ 6. **External Link** - If referencing external content
456
+
457
+ **For Affinity Group Coordinators:**
458
+ - Associate with your affinity group
459
+ - Choose where to share (Announcements page, Bi-Weekly Digest, Affinity Group page, Email to group)
460
+
461
+ **Important:** Announcements are created as **drafts** requiring staff review. You'll receive an edit URL.
462
+
463
+ Let me check your context first, then we'll get started!${topic ? `\n\nI see you want to announce something about "${topic}" - I'll help you craft that.` : ""}`,
464
+ },
465
+ },
466
+ ],
467
+ };
468
+ }
469
+
470
+ if (name === "manage_announcements_guide") {
471
+ return {
472
+ description: "Guide for managing existing announcements",
473
+ messages: [
474
+ {
475
+ role: "user",
476
+ content: {
477
+ type: "text",
478
+ text: "I want to manage my announcements",
479
+ },
480
+ },
481
+ {
482
+ role: "assistant",
483
+ content: {
484
+ type: "text",
485
+ text: `I can help you manage your ACCESS announcements. Here's what you can do:
486
+
487
+ **View Your Announcements**
488
+ Use \`get_my_announcements\` to see all announcements you've created, including:
489
+ - Draft announcements awaiting review
490
+ - Published announcements
491
+ - Their current status
492
+
493
+ **Update an Announcement**
494
+ Use \`update_announcement\` with the announcement's UUID to modify:
495
+ - Title
496
+ - Body content
497
+ - Summary
498
+ - Published date
499
+ - Tags
500
+
501
+ **Delete an Announcement**
502
+ Use \`delete_announcement\` with the UUID to remove an announcement you own.
503
+
504
+ Would you like me to:
505
+ 1. **List your announcements** - See what you've created
506
+ 2. **Update a specific announcement** - Make changes to an existing one
507
+ 3. **Delete an announcement** - Remove one you no longer need
508
+
509
+ Which would you like to do?`,
510
+ },
511
+ },
512
+ ],
513
+ };
514
+ }
515
+
516
+ throw new Error(`Unknown prompt: ${name}`);
517
+ }
518
+
519
+ protected async handleToolCall(request: CallToolRequest): Promise<CallToolResult> {
69
520
  const { name, arguments: args } = request.params;
70
521
 
71
522
  try {
72
523
  switch (name) {
524
+ // Read operations
73
525
  case "search_announcements":
74
- return await this.searchAnnouncements(args);
526
+ return await this.searchAnnouncements(args as SearchAnnouncementsArgs);
527
+
528
+ // CRUD operations
529
+ case "create_announcement":
530
+ return await this.createAnnouncement(args as unknown as CreateAnnouncementArgs);
531
+ case "update_announcement":
532
+ return await this.updateAnnouncement(args as unknown as UpdateAnnouncementArgs);
533
+ case "delete_announcement":
534
+ return await this.deleteAnnouncement(args as unknown as DeleteAnnouncementArgs);
535
+ case "get_my_announcements":
536
+ return await this.getMyAnnouncements(args as unknown as GetMyAnnouncementsArgs);
537
+
538
+ // Helper operations
539
+ case "get_announcement_context":
540
+ return await this.getAnnouncementContext();
541
+
75
542
  default:
76
543
  return this.errorResponse(`Unknown tool: ${name}`);
77
544
  }
78
- } catch (error: any) {
79
- return this.errorResponse(error.message);
545
+ } catch (error) {
546
+ const message = error instanceof Error ? error.message : String(error);
547
+ return this.errorResponse(message);
80
548
  }
81
549
  }
82
550
 
83
- async handleResourceRead(request: any) {
551
+ protected async handleResourceRead(request: ReadResourceRequest): Promise<ReadResourceResult> {
84
552
  const { uri } = request.params;
85
553
 
86
554
  if (uri === "accessci://announcements") {
@@ -95,13 +563,14 @@ export class AnnouncementsServer extends BaseAccessServer {
95
563
  }
96
564
  ]
97
565
  };
98
- } catch (error: any) {
566
+ } catch (error) {
567
+ const message = error instanceof Error ? error.message : String(error);
99
568
  return {
100
569
  contents: [
101
570
  {
102
571
  uri,
103
- mimeType: "text/plain",
104
- text: `Error loading announcements: ${error.message}`
572
+ mimeType: "text/plain",
573
+ text: `Error loading announcements: ${message}`
105
574
  }
106
575
  ]
107
576
  };
@@ -111,27 +580,30 @@ export class AnnouncementsServer extends BaseAccessServer {
111
580
  throw new Error(`Unknown resource: ${uri}`);
112
581
  }
113
582
 
114
- private normalizeLimit(limit?: number): number {
115
- // Drupal API only accepts specific pagination values: 5, 10, 25, 50
116
- const validSizes = [5, 10, 25, 50];
117
- const requestedLimit = limit || 25; // Default to 25 (valid API value)
583
+ // ============================================================================
584
+ // Read Operations (existing)
585
+ // ============================================================================
118
586
 
119
- // Find the closest valid size
587
+ private normalizeLimit(limit?: number): number {
588
+ const requestedLimit = limit || 25;
120
589
  if (requestedLimit <= 5) return 5;
121
590
  if (requestedLimit <= 10) return 10;
122
591
  if (requestedLimit <= 25) return 25;
123
592
  return 50;
124
593
  }
125
594
 
126
- private buildAnnouncementsUrl(filters: any): string {
595
+ private buildAnnouncementsUrl(filters: AnnouncementFilters): string {
127
596
  const params = new URLSearchParams();
128
597
  params.append("items_per_page", String(this.normalizeLimit(filters.limit)));
129
598
 
599
+ if (filters.query) {
600
+ params.append("search_api_fulltext", filters.query);
601
+ }
602
+
130
603
  if (filters.tags) {
131
604
  params.append("tags", filters.tags);
132
605
  }
133
606
 
134
- // Map universal 'date' to API params
135
607
  const dateMap: Record<string, {start: string, end?: string}> = {
136
608
  today: {start: "today"},
137
609
  this_week: {start: "-1 week", end: "now"},
@@ -140,9 +612,10 @@ export class AnnouncementsServer extends BaseAccessServer {
140
612
  };
141
613
 
142
614
  if (filters.date && dateMap[filters.date]) {
143
- params.append("relative_start_date", dateMap[filters.date].start);
144
- if (dateMap[filters.date].end) {
145
- params.append("relative_end_date", dateMap[filters.date].end);
615
+ const dateRange = dateMap[filters.date];
616
+ params.append("relative_start_date", dateRange.start);
617
+ if (dateRange.end) {
618
+ params.append("relative_end_date", dateRange.end);
146
619
  }
147
620
  }
148
621
 
@@ -161,21 +634,26 @@ export class AnnouncementsServer extends BaseAccessServer {
161
634
  return this.enhanceAnnouncements(announcements);
162
635
  }
163
636
 
164
- private enhanceAnnouncements(rawAnnouncements: any[]): Announcement[] {
637
+ private enhanceAnnouncements(rawAnnouncements: Announcement[]): Announcement[] {
165
638
  return rawAnnouncements.map(announcement => ({
166
639
  ...announcement,
167
- tags: Array.isArray(announcement.tags) ? announcement.tags : [],
168
- body_preview: announcement.summary || (announcement.body ? announcement.body.replace(/<[^>]*>/g, '').substring(0, 200) + '...' : '')
640
+ // Tags come as comma-separated string from public API, convert to array
641
+ tags: Array.isArray(announcement.tags)
642
+ ? announcement.tags
643
+ : (typeof announcement.tags === 'string' && announcement.tags.trim()
644
+ ? announcement.tags.split(',').map((t: string) => t.trim())
645
+ : []),
646
+ summary: announcement.summary || (announcement.body ? announcement.body.replace(/<[^>]*>/g, '').substring(0, 200) + '...' : '')
169
647
  }));
170
648
  }
171
649
 
172
- private async searchAnnouncements(filters: any) {
650
+ private async searchAnnouncements(filters: SearchAnnouncementsArgs): Promise<CallToolResult> {
173
651
  const announcements = await this.fetchAnnouncements(filters);
174
652
  const limited = filters.limit ? announcements.slice(0, filters.limit) : announcements;
175
653
 
176
654
  return {
177
655
  content: [{
178
- type: "text",
656
+ type: "text" as const,
179
657
  text: JSON.stringify({
180
658
  total: announcements.length,
181
659
  items: limited
@@ -183,4 +661,504 @@ export class AnnouncementsServer extends BaseAccessServer {
183
661
  }]
184
662
  };
185
663
  }
186
- }
664
+
665
+ // ============================================================================
666
+ // CRUD Operations (new)
667
+ // ============================================================================
668
+
669
+ /**
670
+ * Get the acting user's UID from environment variable.
671
+ * This identifies who the announcement should be attributed to.
672
+ */
673
+ private getActingUserUid(): number {
674
+ const envUid = process.env.ACTING_USER_UID;
675
+ if (envUid) {
676
+ const parsed = parseInt(envUid, 10);
677
+ if (!isNaN(parsed)) {
678
+ return parsed;
679
+ }
680
+ }
681
+
682
+ throw new Error(
683
+ "Cannot create announcement: No acting user specified.\n\n" +
684
+ "The ACTING_USER_UID environment variable must be set to the Drupal user ID " +
685
+ "of the person creating the announcement. This should be configured by the chat system."
686
+ );
687
+ }
688
+
689
+ /**
690
+ * Create a new announcement via Drupal JSON:API
691
+ */
692
+ private async createAnnouncement(args: CreateAnnouncementArgs): Promise<CallToolResult> {
693
+ const auth = this.getDrupalAuth();
694
+
695
+ // Get the acting user - required for proper attribution
696
+ const actingUserUid = this.getActingUserUid();
697
+ const userUuid = await this.getUserUuidByUid(actingUserUid);
698
+
699
+ if (!userUuid) {
700
+ throw new Error(`Could not find user with UID ${actingUserUid}. Verify the ACTING_USER_UID is correct.`);
701
+ }
702
+
703
+ // Build the JSON:API request body
704
+ const requestBody: any = {
705
+ data: {
706
+ type: "node--access_news",
707
+ attributes: {
708
+ title: args.title,
709
+ moderation_state: "draft", // Use content moderation workflow
710
+ body: {
711
+ value: args.body,
712
+ format: "basic_html"
713
+ }
714
+ },
715
+ relationships: {
716
+ uid: {
717
+ data: {
718
+ type: "user--user",
719
+ id: userUuid
720
+ }
721
+ }
722
+ }
723
+ }
724
+ };
725
+
726
+ // Add optional fields
727
+ if (args.summary) {
728
+ requestBody.data.attributes.body.summary = args.summary;
729
+ }
730
+
731
+ if (args.published_date) {
732
+ requestBody.data.attributes.field_published_date = args.published_date;
733
+ } else {
734
+ // Default to today
735
+ requestBody.data.attributes.field_published_date = new Date().toISOString().split('T')[0];
736
+ }
737
+
738
+ if (args.affiliation) {
739
+ requestBody.data.attributes.field_affiliation = args.affiliation;
740
+ }
741
+
742
+ // Look up tag UUIDs if tags provided
743
+ if (args.tags && args.tags.length > 0) {
744
+ const tagUuids = await this.getTagUuidsByName(args.tags);
745
+ if (tagUuids.length > 0) {
746
+ requestBody.data.relationships.field_tags = {
747
+ data: tagUuids.map(uuid => ({
748
+ type: "taxonomy_term--tags",
749
+ id: uuid
750
+ }))
751
+ };
752
+ }
753
+ }
754
+
755
+ // Look up affinity group UUID if provided
756
+ if (args.affinity_group) {
757
+ const groupUuid = await this.getAffinityGroupUuid(args.affinity_group);
758
+ if (groupUuid) {
759
+ requestBody.data.relationships.field_affinity_group_node = {
760
+ data: {
761
+ type: "node--affinity_group",
762
+ id: groupUuid
763
+ }
764
+ };
765
+ } else {
766
+ throw new Error(`Affinity group not found: ${args.affinity_group}. Use list_affinity_groups to see groups you coordinate.`);
767
+ }
768
+ }
769
+
770
+ // Add external link if provided
771
+ if (args.external_link) {
772
+ requestBody.data.attributes.field_news_external_link = {
773
+ uri: args.external_link.uri,
774
+ title: args.external_link.title || ""
775
+ };
776
+ }
777
+
778
+ // Add where to share (defaults handled by Drupal if not provided)
779
+ if (args.where_to_share && args.where_to_share.length > 0) {
780
+ requestBody.data.attributes.field_choose_where_to_share_this = this.normalizeWhereToShare(args.where_to_share);
781
+ }
782
+
783
+ const result = await auth.post("/jsonapi/node/access_news", requestBody);
784
+
785
+ return {
786
+ content: [{
787
+ type: "text" as const,
788
+ text: JSON.stringify({
789
+ success: true,
790
+ message: "Announcement created (draft status)",
791
+ uuid: result.data?.id,
792
+ title: result.data?.attributes?.title,
793
+ edit_url: `${process.env.DRUPAL_API_URL}/node/${result.data?.attributes?.drupal_internal__nid}/edit`
794
+ })
795
+ }]
796
+ };
797
+ }
798
+
799
+ /**
800
+ * Update an existing announcement via Drupal JSON:API
801
+ */
802
+ private async updateAnnouncement(args: UpdateAnnouncementArgs): Promise<CallToolResult> {
803
+ const auth = this.getDrupalAuth();
804
+
805
+ const requestBody: any = {
806
+ data: {
807
+ type: "node--access_news",
808
+ id: args.uuid,
809
+ attributes: {}
810
+ }
811
+ };
812
+
813
+ // Add fields to update
814
+ if (args.title) {
815
+ requestBody.data.attributes.title = args.title;
816
+ }
817
+
818
+ // Handle body/summary updates
819
+ // Fetch existing to preserve values not being changed
820
+ if (args.body || args.summary) {
821
+ const existing = await auth.get(`/jsonapi/node/access_news/${args.uuid}`);
822
+ const existingBody = existing.data?.attributes?.body?.value || "";
823
+ const existingSummary = existing.data?.attributes?.body?.summary || "";
824
+
825
+ requestBody.data.attributes.body = {
826
+ value: args.body || existingBody,
827
+ format: "basic_html",
828
+ summary: args.summary !== undefined ? args.summary : existingSummary
829
+ };
830
+ }
831
+ // If neither body nor summary provided, don't include body in request at all
832
+
833
+ if (args.published_date) {
834
+ requestBody.data.attributes.field_published_date = args.published_date;
835
+ }
836
+
837
+ // Update tags if provided
838
+ if (args.tags && args.tags.length > 0) {
839
+ const tagUuids = await this.getTagUuidsByName(args.tags);
840
+ if (tagUuids.length > 0) {
841
+ if (!requestBody.data.relationships) {
842
+ requestBody.data.relationships = {};
843
+ }
844
+ requestBody.data.relationships.field_tags = {
845
+ data: tagUuids.map(uuid => ({
846
+ type: "taxonomy_term--tags",
847
+ id: uuid
848
+ }))
849
+ };
850
+ }
851
+ }
852
+
853
+ // Update affinity group if provided
854
+ if (args.affinity_group) {
855
+ const groupUuid = await this.getAffinityGroupUuid(args.affinity_group);
856
+ if (groupUuid) {
857
+ if (!requestBody.data.relationships) {
858
+ requestBody.data.relationships = {};
859
+ }
860
+ requestBody.data.relationships.field_affinity_group_node = {
861
+ data: {
862
+ type: "node--affinity_group",
863
+ id: groupUuid
864
+ }
865
+ };
866
+ } else {
867
+ throw new Error(`Affinity group not found: ${args.affinity_group}. Use list_affinity_groups to see groups you coordinate.`);
868
+ }
869
+ }
870
+
871
+ // Update external link if provided
872
+ if (args.external_link) {
873
+ requestBody.data.attributes.field_news_external_link = {
874
+ uri: args.external_link.uri,
875
+ title: args.external_link.title || ""
876
+ };
877
+ }
878
+
879
+ // Update where to share if provided
880
+ if (args.where_to_share && args.where_to_share.length > 0) {
881
+ requestBody.data.attributes.field_choose_where_to_share_this = this.normalizeWhereToShare(args.where_to_share);
882
+ }
883
+
884
+ const result = await auth.patch(`/jsonapi/node/access_news/${args.uuid}`, requestBody);
885
+ const nid = result.data?.attributes?.drupal_internal__nid;
886
+ const baseUrl = process.env.DRUPAL_API_URL;
887
+
888
+ return {
889
+ content: [{
890
+ type: "text" as const,
891
+ text: JSON.stringify({
892
+ success: true,
893
+ message: "Announcement updated",
894
+ uuid: result.data?.id,
895
+ title: result.data?.attributes?.title,
896
+ edit_url: nid ? `${baseUrl}/node/${nid}/edit` : null
897
+ })
898
+ }]
899
+ };
900
+ }
901
+
902
+ /**
903
+ * Delete an announcement via Drupal JSON:API
904
+ */
905
+ private async deleteAnnouncement(args: DeleteAnnouncementArgs): Promise<CallToolResult> {
906
+ // Enforce confirmation parameter
907
+ if (!args.confirmed) {
908
+ return {
909
+ content: [{
910
+ type: "text" as const,
911
+ text: JSON.stringify({
912
+ error: "Deletion requires explicit confirmation. You must show the announcement title and status to the user and get explicit confirmation before setting confirmed=true.",
913
+ uuid: args.uuid
914
+ })
915
+ }]
916
+ };
917
+ }
918
+
919
+ const auth = this.getDrupalAuth();
920
+
921
+ await auth.delete(`/jsonapi/node/access_news/${args.uuid}`);
922
+
923
+ return {
924
+ content: [{
925
+ type: "text" as const,
926
+ text: JSON.stringify({
927
+ success: true,
928
+ message: "Announcement deleted",
929
+ uuid: args.uuid
930
+ })
931
+ }]
932
+ };
933
+ }
934
+
935
+ /**
936
+ * Get announcements created by the acting user
937
+ */
938
+ private async getMyAnnouncements(args: GetMyAnnouncementsArgs): Promise<CallToolResult> {
939
+ const auth = this.getDrupalAuth();
940
+
941
+ // Get the acting user's UUID
942
+ const actingUserUid = this.getActingUserUid();
943
+ const userUuid = await this.getUserUuidByUid(actingUserUid);
944
+
945
+ if (!userUuid) {
946
+ throw new Error(`Could not find user with UID ${actingUserUid}. Verify the ACTING_USER_UID is correct.`);
947
+ }
948
+
949
+ const limit = args.limit || 25;
950
+ const result = await auth.get(
951
+ `/jsonapi/node/access_news?filter[uid.id]=${userUuid}&page[limit]=${limit}&sort=-created`
952
+ );
953
+
954
+ const baseUrl = process.env.DRUPAL_API_URL;
955
+ const announcements = (result.data || []).map((item: any) => {
956
+ const nid = item.attributes?.drupal_internal__nid;
957
+ return {
958
+ uuid: item.id,
959
+ nid,
960
+ title: item.attributes?.title,
961
+ status: item.attributes?.status ? "published" : "draft",
962
+ created: item.attributes?.created,
963
+ published_date: item.attributes?.field_published_date,
964
+ summary: item.attributes?.body?.summary ||
965
+ (item.attributes?.body?.value ?
966
+ item.attributes.body.value.replace(/<[^>]*>/g, '').substring(0, 200) + '...' : ''),
967
+ edit_url: nid ? `${baseUrl}/node/${nid}/edit` : null
968
+ };
969
+ });
970
+
971
+ return {
972
+ content: [{
973
+ type: "text" as const,
974
+ text: JSON.stringify({
975
+ total: announcements.length,
976
+ items: announcements
977
+ })
978
+ }]
979
+ };
980
+ }
981
+
982
+ // ============================================================================
983
+ // Helper Methods
984
+ // ============================================================================
985
+
986
+ /**
987
+ * Get a user's UUID by their Drupal user ID (internal helper)
988
+ */
989
+ private async getUserUuidByUid(uid: number): Promise<string | null> {
990
+ const auth = this.getDrupalAuth();
991
+ const result = await auth.get(`/jsonapi/user/user?filter[drupal_internal__uid]=${uid}`);
992
+
993
+ if (!result.data || result.data.length === 0) {
994
+ return null;
995
+ }
996
+
997
+ return result.data[0].id;
998
+ }
999
+
1000
+ /**
1001
+ * Get announcement context - tags, affinity groups, and options for creating announcements
1002
+ */
1003
+ private async getAnnouncementContext(): Promise<CallToolResult> {
1004
+ const auth = this.getDrupalAuth();
1005
+
1006
+ // Get the acting user's UUID
1007
+ const actingUserUid = this.getActingUserUid();
1008
+ const userUuid = await this.getUserUuidByUid(actingUserUid);
1009
+
1010
+ if (!userUuid) {
1011
+ throw new Error(`Could not find user with UID ${actingUserUid}`);
1012
+ }
1013
+
1014
+ // Fetch tags and affinity groups in parallel
1015
+ const [tagsResult, groupsResult] = await Promise.all([
1016
+ auth.get("/jsonapi/taxonomy_term/tags?page[limit]=100"),
1017
+ auth.get(`/jsonapi/node/affinity_group?filter[field_coordinator.id]=${userUuid}&filter[status]=1&page[limit]=100`)
1018
+ ]);
1019
+
1020
+ const tags = (tagsResult.data || []).map((item: any) => ({
1021
+ name: item.attributes?.name,
1022
+ uuid: item.id
1023
+ }));
1024
+
1025
+ const affinityGroups = (groupsResult.data || []).map((item: any) => ({
1026
+ id: item.attributes?.field_group_id,
1027
+ uuid: item.id,
1028
+ name: item.attributes?.title,
1029
+ category: item.attributes?.field_affinity_group_category
1030
+ }));
1031
+
1032
+ const isCoordinator = affinityGroups.length > 0;
1033
+
1034
+ return {
1035
+ content: [{
1036
+ type: "text" as const,
1037
+ text: JSON.stringify({
1038
+ tags: tags,
1039
+ affinity_groups: affinityGroups,
1040
+ is_coordinator: isCoordinator,
1041
+ affiliations: ["ACCESS Collaboration", "Community"],
1042
+ where_to_share_options: [
1043
+ { value: "Announcements page", description: "Public announcements listing" },
1044
+ { value: "Bi-Weekly Digest", description: "ACCESS Support bi-weekly email digest" },
1045
+ { value: "Affinity Group page", description: "Your affinity group's page (coordinators only)" },
1046
+ { value: "Email to Affinity Group", description: "Direct email to affinity group members (coordinators only)" }
1047
+ ],
1048
+ guidance: isCoordinator
1049
+ ? "User is an affinity group coordinator. Ask about affinity_group association and where_to_share preferences."
1050
+ : "User is not a coordinator. Standard announcement fields apply."
1051
+ })
1052
+ }]
1053
+ };
1054
+ }
1055
+
1056
+ /**
1057
+ * Look up affinity group UUID by ID or name
1058
+ */
1059
+ private async getAffinityGroupUuid(idOrName: string): Promise<string | null> {
1060
+ const auth = this.getDrupalAuth();
1061
+
1062
+ // If it looks like a UUID, return as-is
1063
+ if (idOrName.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) {
1064
+ return idOrName;
1065
+ }
1066
+
1067
+ // Try by field_group_id first
1068
+ let result = await auth.get(
1069
+ `/jsonapi/node/affinity_group?filter[field_group_id]=${encodeURIComponent(idOrName)}&filter[status]=1`
1070
+ );
1071
+
1072
+ if (result.data && result.data.length > 0) {
1073
+ return result.data[0].id;
1074
+ }
1075
+
1076
+ // Try by title
1077
+ result = await auth.get(
1078
+ `/jsonapi/node/affinity_group?filter[title]=${encodeURIComponent(idOrName)}&filter[status]=1`
1079
+ );
1080
+
1081
+ if (result.data && result.data.length > 0) {
1082
+ return result.data[0].id;
1083
+ }
1084
+
1085
+ return null;
1086
+ }
1087
+
1088
+ /**
1089
+ * Map human-friendly "where to share" labels to Drupal values
1090
+ */
1091
+ private static WHERE_TO_SHARE_MAP: Record<string, string> = {
1092
+ "announcements page": "on_the_announcements_page",
1093
+ "on the announcements page": "on_the_announcements_page",
1094
+ "bi-weekly digest": "in_the_access_support_bi_weekly_digest",
1095
+ "in the access support bi-weekly digest": "in_the_access_support_bi_weekly_digest",
1096
+ "affinity group page": "on_your_affinity_group_page",
1097
+ "on your affinity group page": "on_your_affinity_group_page",
1098
+ "email to affinity group": "email_to_your_affinity_group",
1099
+ "email to your affinity group": "email_to_your_affinity_group",
1100
+ // Also accept the raw values
1101
+ "on_the_announcements_page": "on_the_announcements_page",
1102
+ "in_the_access_support_bi_weekly_digest": "in_the_access_support_bi_weekly_digest",
1103
+ "on_your_affinity_group_page": "on_your_affinity_group_page",
1104
+ "email_to_your_affinity_group": "email_to_your_affinity_group",
1105
+ };
1106
+
1107
+ private normalizeWhereToShare(values: string[]): string[] {
1108
+ return values.map(v => {
1109
+ const normalized = AnnouncementsServer.WHERE_TO_SHARE_MAP[v.toLowerCase()];
1110
+ if (!normalized) {
1111
+ throw new Error(
1112
+ `Invalid where_to_share value: "${v}". ` +
1113
+ `Valid options: 'Announcements page', 'Bi-Weekly Digest', 'Affinity Group page', 'Email to Affinity Group'`
1114
+ );
1115
+ }
1116
+ return normalized;
1117
+ });
1118
+ }
1119
+
1120
+ /**
1121
+ * Check if tag cache is still valid
1122
+ */
1123
+ private isTagCacheValid(): boolean {
1124
+ return this.tagCacheExpiry !== undefined && new Date() < this.tagCacheExpiry;
1125
+ }
1126
+
1127
+ /**
1128
+ * Populate the tag cache with all available tags
1129
+ */
1130
+ private async populateTagCache(): Promise<void> {
1131
+ const auth = this.getDrupalAuth();
1132
+ const result = await auth.get("/jsonapi/taxonomy_term/tags?page[limit]=500");
1133
+
1134
+ this.tagCache.clear();
1135
+ for (const item of result.data || []) {
1136
+ const name = item.attributes?.name?.toLowerCase();
1137
+ if (name && item.id) {
1138
+ this.tagCache.set(name, item.id);
1139
+ }
1140
+ }
1141
+
1142
+ this.tagCacheExpiry = new Date(Date.now() + AnnouncementsServer.TAG_CACHE_TTL_MS);
1143
+ }
1144
+
1145
+ /**
1146
+ * Get tag UUIDs by their names (with caching)
1147
+ */
1148
+ private async getTagUuidsByName(tagNames: string[]): Promise<string[]> {
1149
+ // Ensure cache is populated
1150
+ if (!this.isTagCacheValid()) {
1151
+ await this.populateTagCache();
1152
+ }
1153
+
1154
+ const uuids: string[] = [];
1155
+ for (const name of tagNames) {
1156
+ const uuid = this.tagCache.get(name.toLowerCase());
1157
+ if (uuid) {
1158
+ uuids.push(uuid);
1159
+ }
1160
+ }
1161
+
1162
+ return uuids;
1163
+ }
1164
+ }