@access-mcp/announcements 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts CHANGED
@@ -1,5 +1,18 @@
1
- import { BaseAccessServer, Tool, Resource, CallToolResult, DrupalAuthProvider } from "@access-mcp/shared";
2
- import { CallToolRequest, ReadResourceRequest, ReadResourceResult, GetPromptResult, Prompt } from "@modelcontextprotocol/sdk/types.js";
1
+ import {
2
+ BaseAccessServer,
3
+ Tool,
4
+ Resource,
5
+ CallToolResult,
6
+ DrupalAuthProvider,
7
+ getRequestContext,
8
+ } from "@access-mcp/shared";
9
+ import {
10
+ CallToolRequest,
11
+ ReadResourceRequest,
12
+ ReadResourceResult,
13
+ GetPromptResult,
14
+ Prompt,
15
+ } from "@modelcontextprotocol/sdk/types.js";
3
16
  import { createRequire } from "module";
4
17
  const require = createRequire(import.meta.url);
5
18
  const { version } = require("../package.json");
@@ -29,12 +42,12 @@ interface AnnouncementFilters {
29
42
  }
30
43
 
31
44
  interface Announcement {
32
- uuid?: string; // Added in API v2.2 update
45
+ uuid?: string; // Added in API v2.2 update
33
46
  title: string;
34
47
  body: string;
35
48
  published_date: string;
36
49
  affinity_group: string | string[];
37
- tags: string | string[]; // API returns comma-separated string, we convert to array
50
+ tags: string | string[]; // API returns comma-separated string, we convert to array
38
51
  affiliation: string;
39
52
  summary?: string;
40
53
  }
@@ -72,6 +85,66 @@ interface GetMyAnnouncementsArgs {
72
85
  limit?: number;
73
86
  }
74
87
 
88
+ // JSON:API request/response types for Drupal
89
+ interface JsonApiRelationshipData {
90
+ type: string;
91
+ id: string;
92
+ }
93
+
94
+ interface JsonApiRelationship {
95
+ data: JsonApiRelationshipData | JsonApiRelationshipData[];
96
+ }
97
+
98
+ interface JsonApiBodyField {
99
+ value: string;
100
+ format?: string;
101
+ summary?: string;
102
+ }
103
+
104
+ interface JsonApiLinkField {
105
+ uri: string;
106
+ title?: string;
107
+ }
108
+
109
+ interface JsonApiRequestAttributes {
110
+ title?: string;
111
+ moderation_state?: string;
112
+ body?: JsonApiBodyField;
113
+ field_published_date?: string;
114
+ field_affiliation?: string;
115
+ field_news_external_link?: JsonApiLinkField;
116
+ field_where_to_share?: string[];
117
+ [key: string]: unknown; // Allow additional fields
118
+ }
119
+
120
+ interface JsonApiRequestBody {
121
+ data: {
122
+ type: string;
123
+ id?: string;
124
+ attributes: JsonApiRequestAttributes;
125
+ relationships?: Record<string, JsonApiRelationship>;
126
+ };
127
+ }
128
+
129
+ interface JsonApiResourceItem {
130
+ id: string;
131
+ type: string;
132
+ attributes?: {
133
+ drupal_internal__nid?: number;
134
+ title?: string;
135
+ status?: boolean;
136
+ created?: string;
137
+ name?: string;
138
+ field_published_date?: string;
139
+ field_group_id?: string;
140
+ field_affinity_group_category?: string;
141
+ body?: {
142
+ value?: string;
143
+ summary?: string;
144
+ };
145
+ };
146
+ }
147
+
75
148
  // ============================================================================
76
149
  // Server
77
150
  // ============================================================================
@@ -83,12 +156,18 @@ export class AnnouncementsServer extends BaseAccessServer {
83
156
  private static TAG_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
84
157
 
85
158
  constructor() {
86
- super("access-announcements", version, "https://support.access-ci.org");
159
+ super("access-announcements", version, "https://support.access-ci.org", {
160
+ requireApiKey: true,
161
+ });
87
162
  }
88
163
 
89
164
  /**
90
165
  * Get or create the Drupal auth provider for JSON:API write operations.
91
166
  * Requires DRUPAL_API_URL, DRUPAL_USERNAME, and DRUPAL_PASSWORD env vars.
167
+ *
168
+ * Acting user is determined in priority order:
169
+ * 1. X-Acting-User header (from request context)
170
+ * 2. ACTING_USER environment variable (fallback)
92
171
  */
93
172
  private getDrupalAuth(): DrupalAuthProvider {
94
173
  if (!this.drupalAuth) {
@@ -104,6 +183,14 @@ export class AnnouncementsServer extends BaseAccessServer {
104
183
 
105
184
  this.drupalAuth = new DrupalAuthProvider(baseUrl, username, password);
106
185
  }
186
+
187
+ // Update acting user from request context (takes priority) or env var (fallback)
188
+ const context = getRequestContext();
189
+ const actingUser = context?.actingUser || process.env.ACTING_USER;
190
+ if (actingUser) {
191
+ this.drupalAuth.setActingUser(actingUser);
192
+ }
193
+
107
194
  return this.drupalAuth;
108
195
  }
109
196
 
@@ -112,30 +199,33 @@ export class AnnouncementsServer extends BaseAccessServer {
112
199
  // Read operations (existing)
113
200
  {
114
201
  name: "search_announcements",
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}]}.",
202
+ description:
203
+ "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}]}.",
116
204
  inputSchema: {
117
205
  type: "object",
118
206
  properties: {
119
207
  query: {
120
208
  type: "string",
121
- description: "Full-text search across title, body, and summary"
209
+ description: "Full-text search across title, body, and summary",
122
210
  },
123
211
  tags: {
124
212
  type: "string",
125
- description: "Filter by topics: gpu, ml, hpc"
213
+ description:
214
+ "Filter by tag name. Examples: gpu, ml, hpc, open-ondemand, ACCESS-allocations, ACCESS-account, ai, pegasus. Use suggest_tags to discover tags from announcement content.",
126
215
  },
127
216
  date: {
128
217
  type: "string",
129
- description: "Time period filter: today, this_week (last 7 days), this_month (last 30 days), past (last year)",
130
- enum: ["today", "this_week", "this_month", "past"]
218
+ description:
219
+ "Time period filter: today, this_week (last 7 days), this_month (last 30 days), past (last year)",
220
+ enum: ["today", "this_week", "this_month", "past"],
131
221
  },
132
222
  limit: {
133
223
  type: "number",
134
224
  description: "Max results (default: 25)",
135
- default: 25
136
- }
137
- }
138
- }
225
+ default: 25,
226
+ },
227
+ },
228
+ },
139
229
  },
140
230
  // CRUD operations (new)
141
231
  {
@@ -143,12 +233,12 @@ export class AnnouncementsServer extends BaseAccessServer {
143
233
  description: `Create a new ACCESS announcement (saved as draft for staff review).
144
234
 
145
235
  BEFORE CALLING:
146
- 1. Call get_announcement_context first to get tags, check coordinator status, and tailor the conversation.
236
+ 1. Call get_announcement_context to check coordinator status.
147
237
  2. Gather information - either conversationally OR by parsing pasted content:
148
238
  - title (required): Clear, specific headline
149
239
  - body (required): Full content with details, dates, links. HTML supported.
150
240
  - summary (required): 1-2 sentence teaser for listings
151
- - tags (recommended): 1-6 tags help users find it
241
+ - tags (required): Call suggest_tags with the body text to get tag suggestions, then include them
152
242
  - affiliation (optional): "ACCESS Collaboration" or "Community" (default)
153
243
  - external_link (if relevant): URL and link text for external references
154
244
  3. IF user is a coordinator (check get_announcement_context response):
@@ -159,19 +249,22 @@ WORKFLOW:
159
249
  If user pastes content (event description, draft text, etc.):
160
250
  1. Call get_announcement_context
161
251
  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
252
+ 3. Call suggest_tags with the body text — include returned tag names in the tags parameter
253
+ 4. Call suggest_summary with the body text if no summary provided
254
+ 5. Ask only about missing/unclear fields
255
+ 6. If coordinator: ask about affinity group and where to share
256
+ 7. Show preview (including tags) and get confirmation
257
+ 8. Create the announcement with ALL fields including tags
167
258
 
168
259
  If user describes what they want conversationally:
169
260
  1. Call get_announcement_context
170
261
  2. Guide them through providing title, body, summary
171
- 3. Suggest relevant tags from context
262
+ 3. Call suggest_tags with the body text — include returned tag names in the tags parameter
172
263
  4. If coordinator: ask about affinity group and where to share
173
- 5. Show preview and get confirmation
174
- 6. Create the announcement
264
+ 5. Show preview (including tags) and get confirmation
265
+ 6. Create the announcement with ALL fields including tags
266
+
267
+ IMPORTANT: Always pass the tags parameter when creating. Tags from suggest_tags are tag names (strings) — pass them as the tags array.
175
268
 
176
269
  PREVIEW FORMAT (show before creating):
177
270
  ---
@@ -196,51 +289,55 @@ ALWAYS display the edit_url to the user so they can review their draft in Drupal
196
289
  properties: {
197
290
  title: {
198
291
  type: "string",
199
- description: "Announcement title - clear and specific, under 100 characters"
292
+ description: "Announcement title - clear and specific, under 100 characters",
200
293
  },
201
294
  body: {
202
295
  type: "string",
203
- description: "Full announcement content. HTML allowed (basic_html format: <p>, <a>, <strong>, <em>, <ul>, <li>)"
296
+ description:
297
+ "Full announcement content. HTML allowed (basic_html format: <p>, <a>, <strong>, <em>, <ul>, <li>)",
204
298
  },
205
299
  summary: {
206
300
  type: "string",
207
- description: "Brief teaser (1-2 sentences) shown in announcement listings. Required."
301
+ description: "Brief teaser (1-2 sentences) shown in announcement listings. Required.",
208
302
  },
209
303
  published_date: {
210
304
  type: "string",
211
- description: "When to display (YYYY-MM-DD). Defaults to today."
305
+ description: "When to display (YYYY-MM-DD). Defaults to today.",
212
306
  },
213
307
  tags: {
214
308
  type: "array",
215
309
  items: { type: "string" },
216
- description: "Tag names (1-6 required for publication). Use list_available_tags to find valid tags."
310
+ description:
311
+ 'Tag names as strings, e.g. ["ai", "machine-learning", "gpu"]. Get these from the "name" field in suggest_tags response.',
217
312
  },
218
313
  affiliation: {
219
314
  type: "string",
220
315
  description: "Source of announcement",
221
- enum: ["ACCESS Collaboration", "Community"]
316
+ enum: ["ACCESS Collaboration", "Community"],
222
317
  },
223
318
  affinity_group: {
224
319
  type: "string",
225
- description: "Affinity group name or UUID. User must be coordinator of the group. Use list_affinity_groups to find valid groups."
320
+ description:
321
+ "Affinity group name or UUID. User must be coordinator of the group. Use list_affinity_groups to find valid groups.",
226
322
  },
227
323
  external_link: {
228
324
  type: "object",
229
325
  description: "External link related to this announcement",
230
326
  properties: {
231
327
  uri: { type: "string", description: "URL (must include https://)" },
232
- title: { type: "string", description: "Link text to display" }
328
+ title: { type: "string", description: "Link text to display" },
233
329
  },
234
- required: ["uri"]
330
+ required: ["uri"],
235
331
  },
236
332
  where_to_share: {
237
333
  type: "array",
238
334
  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
- }
335
+ description:
336
+ "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'",
337
+ },
241
338
  },
242
- required: ["title", "body", "summary"]
243
- }
339
+ required: ["title", "body", "summary"],
340
+ },
244
341
  },
245
342
  {
246
343
  name: "update_announcement",
@@ -259,50 +356,51 @@ ALWAYS display the edit_url to the user so they can review changes in Drupal.`,
259
356
  properties: {
260
357
  uuid: {
261
358
  type: "string",
262
- description: "UUID of the announcement (get from get_my_announcements)"
359
+ description: "UUID of the announcement (get from get_my_announcements)",
263
360
  },
264
361
  title: {
265
362
  type: "string",
266
- description: "New title (only if changing)"
363
+ description: "New title (only if changing)",
267
364
  },
268
365
  body: {
269
366
  type: "string",
270
- description: "New body content (only if changing)"
367
+ description: "New body content (only if changing)",
271
368
  },
272
369
  summary: {
273
370
  type: "string",
274
- description: "New summary (only if changing)"
371
+ description: "New summary (only if changing)",
275
372
  },
276
373
  published_date: {
277
374
  type: "string",
278
- description: "New publication date YYYY-MM-DD (only if changing)"
375
+ description: "New publication date YYYY-MM-DD (only if changing)",
279
376
  },
280
377
  tags: {
281
378
  type: "array",
282
379
  items: { type: "string" },
283
- description: "New tags - replaces ALL existing tags (only if changing)"
380
+ description: "New tags - replaces ALL existing tags (only if changing)",
284
381
  },
285
382
  affinity_group: {
286
383
  type: "string",
287
- description: "Affinity group name or UUID. User must be coordinator."
384
+ description: "Affinity group name or UUID. User must be coordinator.",
288
385
  },
289
386
  external_link: {
290
387
  type: "object",
291
388
  description: "External link related to this announcement",
292
389
  properties: {
293
390
  uri: { type: "string", description: "URL (must include https://)" },
294
- title: { type: "string", description: "Link text to display" }
391
+ title: { type: "string", description: "Link text to display" },
295
392
  },
296
- required: ["uri"]
393
+ required: ["uri"],
297
394
  },
298
395
  where_to_share: {
299
396
  type: "array",
300
397
  items: { type: "string" },
301
- description: "Where to share the announcement. Options: 'Announcements page', 'Bi-Weekly Digest', 'Affinity Group page', 'Email to Affinity Group'"
302
- }
398
+ description:
399
+ "Where to share the announcement. Options: 'Announcements page', 'Bi-Weekly Digest', 'Affinity Group page', 'Email to Affinity Group'",
400
+ },
303
401
  },
304
- required: ["uuid"]
305
- }
402
+ required: ["uuid"],
403
+ },
306
404
  },
307
405
  {
308
406
  name: "delete_announcement",
@@ -326,15 +424,16 @@ Returns: {success, uuid}`,
326
424
  properties: {
327
425
  uuid: {
328
426
  type: "string",
329
- description: "UUID of the announcement to delete"
427
+ description: "UUID of the announcement to delete",
330
428
  },
331
429
  confirmed: {
332
430
  type: "boolean",
333
- description: "Set to true only after user has explicitly confirmed deletion of THIS specific announcement. Required."
334
- }
431
+ description:
432
+ "Set to true only after user has explicitly confirmed deletion of THIS specific announcement. Required.",
433
+ },
335
434
  },
336
- required: ["uuid", "confirmed"]
337
- }
435
+ required: ["uuid", "confirmed"],
436
+ },
338
437
  },
339
438
  {
340
439
  name: "get_my_announcements",
@@ -353,28 +452,59 @@ Use this to:
353
452
  limit: {
354
453
  type: "number",
355
454
  description: "Max results (default: 25)",
356
- default: 25
357
- }
358
- }
359
- }
455
+ default: 25,
456
+ },
457
+ },
458
+ },
360
459
  },
361
460
  {
362
461
  name: "get_announcement_context",
363
462
  description: `Get user context and options BEFORE creating an announcement. Call this first.
364
463
 
365
464
  Returns:
366
- - tags: Available tags for announcements [{name, uuid}]
367
465
  - affinity_groups: Groups the user coordinates (empty if not a coordinator)
368
466
  - is_coordinator: Boolean - if true, ask about affinity_group and where_to_share
369
467
  - affiliations: Available affiliation options
370
468
  - where_to_share_options: Available sharing options (only relevant for coordinators)
371
469
 
372
- Use this to tailor the announcement creation conversation based on the user's role.`,
470
+ Does NOT return tags use suggest_tags after the user provides content.`,
373
471
  inputSchema: {
374
472
  type: "object",
375
- properties: {}
376
- }
377
- }
473
+ properties: {},
474
+ },
475
+ },
476
+ {
477
+ name: "suggest_tags",
478
+ description: `Suggest relevant tags for announcement content. Call this AFTER the user provides their announcement body text. Returns {tags: [{tid, name, uuid}]}. Pass the "name" values as the tags array when calling create_announcement or update_announcement.`,
479
+ inputSchema: {
480
+ type: "object",
481
+ properties: {
482
+ text: {
483
+ type: "string",
484
+ description: "The announcement body text to analyze for tag suggestions. Must be at least 100 characters.",
485
+ },
486
+ limit: {
487
+ type: "number",
488
+ description: "Maximum number of tags to suggest (default: 6)",
489
+ },
490
+ },
491
+ required: ["text"],
492
+ },
493
+ },
494
+ {
495
+ name: "suggest_summary",
496
+ description: `Generate a concise summary for announcement content. Call this to auto-generate a summary from the announcement body. Returns a summary of up to 150 characters.`,
497
+ inputSchema: {
498
+ type: "object",
499
+ properties: {
500
+ text: {
501
+ type: "string",
502
+ description: "The announcement body text to summarize. Must be at least 100 characters.",
503
+ },
504
+ },
505
+ required: ["text"],
506
+ },
507
+ },
378
508
  ];
379
509
  }
380
510
 
@@ -384,8 +514,8 @@ Use this to tailor the announcement creation conversation based on the user's ro
384
514
  uri: "accessci://announcements",
385
515
  name: "ACCESS Support Announcements",
386
516
  description: "Recent announcements and notifications from ACCESS support",
387
- mimeType: "application/json"
388
- }
517
+ mimeType: "application/json",
518
+ },
389
519
  ];
390
520
  }
391
521
 
@@ -397,7 +527,8 @@ Use this to tailor the announcement creation conversation based on the user's ro
397
527
  arguments: [
398
528
  {
399
529
  name: "topic",
400
- description: "Brief description of what the announcement is about (optional, helps tailor guidance)",
530
+ description:
531
+ "Brief description of what the announcement is about (optional, helps tailor guidance)",
401
532
  required: false,
402
533
  },
403
534
  ],
@@ -410,7 +541,9 @@ Use this to tailor the announcement creation conversation based on the user's ro
410
541
  ];
411
542
  }
412
543
 
413
- protected async handleGetPrompt(request: { params: { name: string; arguments?: Record<string, string> } }): Promise<GetPromptResult> {
544
+ protected async handleGetPrompt(request: {
545
+ params: { name: string; arguments?: Record<string, string> };
546
+ }): Promise<GetPromptResult> {
414
547
  const { name, arguments: args = {} } = request.params;
415
548
 
416
549
  if (name === "create_announcement_guide") {
@@ -432,9 +565,9 @@ Use this to tailor the announcement creation conversation based on the user's ro
432
565
  role: "assistant",
433
566
  content: {
434
567
  type: "text",
435
- text: `I'll help you create an ACCESS announcement. Let me first check your available options and coordinator status.
568
+ text: `I'll help you create an ACCESS announcement. Let me check your options first.
436
569
 
437
- First, I'll call \`get_announcement_context\` to see what tags are available and whether you coordinate any affinity groups.
570
+ Once you provide the content, I'll suggest relevant tags and generate a summary automatically.
438
571
 
439
572
  **Required Information:**
440
573
 
@@ -538,6 +671,10 @@ Which would you like to do?`,
538
671
  // Helper operations
539
672
  case "get_announcement_context":
540
673
  return await this.getAnnouncementContext();
674
+ case "suggest_tags":
675
+ return await this.suggestTags(args as { text: string; limit?: number });
676
+ case "suggest_summary":
677
+ return await this.suggestSummary(args as { text: string });
541
678
 
542
679
  default:
543
680
  return this.errorResponse(`Unknown tool: ${name}`);
@@ -559,9 +696,9 @@ Which would you like to do?`,
559
696
  {
560
697
  uri,
561
698
  mimeType: "application/json",
562
- text: JSON.stringify(announcements, null, 2)
563
- }
564
- ]
699
+ text: JSON.stringify(announcements, null, 2),
700
+ },
701
+ ],
565
702
  };
566
703
  } catch (error) {
567
704
  const message = error instanceof Error ? error.message : String(error);
@@ -570,9 +707,9 @@ Which would you like to do?`,
570
707
  {
571
708
  uri,
572
709
  mimeType: "text/plain",
573
- text: `Error loading announcements: ${message}`
574
- }
575
- ]
710
+ text: `Error loading announcements: ${message}`,
711
+ },
712
+ ],
576
713
  };
577
714
  }
578
715
  }
@@ -604,11 +741,11 @@ Which would you like to do?`,
604
741
  params.append("tags", filters.tags);
605
742
  }
606
743
 
607
- const dateMap: Record<string, {start: string, end?: string}> = {
608
- today: {start: "today"},
609
- this_week: {start: "-1 week", end: "now"},
610
- this_month: {start: "-1 month", end: "now"},
611
- past: {start: "-1 year", end: "now"}
744
+ const dateMap: Record<string, { start: string; end?: string }> = {
745
+ today: { start: "today" },
746
+ this_week: { start: "-1 week", end: "now" },
747
+ this_month: { start: "-1 month", end: "now" },
748
+ past: { start: "-1 year", end: "now" },
612
749
  };
613
750
 
614
751
  if (filters.date && dateMap[filters.date]) {
@@ -635,15 +772,19 @@ Which would you like to do?`,
635
772
  }
636
773
 
637
774
  private enhanceAnnouncements(rawAnnouncements: Announcement[]): Announcement[] {
638
- return rawAnnouncements.map(announcement => ({
775
+ return rawAnnouncements.map((announcement) => ({
639
776
  ...announcement,
640
777
  // Tags come as comma-separated string from public API, convert to array
641
778
  tags: Array.isArray(announcement.tags)
642
779
  ? 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) + '...' : '')
780
+ : typeof announcement.tags === "string" && announcement.tags.trim()
781
+ ? announcement.tags.split(",").map((t: string) => t.trim())
782
+ : [],
783
+ summary:
784
+ announcement.summary ||
785
+ (announcement.body
786
+ ? announcement.body.replace(/<[^>]*>/g, "").substring(0, 200) + "..."
787
+ : ""),
647
788
  }));
648
789
  }
649
790
 
@@ -652,13 +793,15 @@ Which would you like to do?`,
652
793
  const limited = filters.limit ? announcements.slice(0, filters.limit) : announcements;
653
794
 
654
795
  return {
655
- content: [{
656
- type: "text" as const,
657
- text: JSON.stringify({
658
- total: announcements.length,
659
- items: limited
660
- })
661
- }]
796
+ content: [
797
+ {
798
+ type: "text" as const,
799
+ text: JSON.stringify({
800
+ total: announcements.length,
801
+ items: limited,
802
+ }),
803
+ },
804
+ ],
662
805
  };
663
806
  }
664
807
 
@@ -667,41 +810,47 @@ Which would you like to do?`,
667
810
  // ============================================================================
668
811
 
669
812
  /**
670
- * Get the acting user's UID from environment variable.
671
- * This identifies who the announcement should be attributed to.
813
+ * Get the acting user's ACCESS ID for content attribution.
814
+ *
815
+ * Priority order:
816
+ * 1. X-Acting-User header (from request context)
817
+ * 2. ACTING_USER environment variable (fallback)
818
+ *
819
+ * Returns the ACCESS ID (e.g., "username@access-ci.org")
672
820
  */
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
- }
821
+ private getActingUserAccessId(): string {
822
+ // Try request context first
823
+ const context = getRequestContext();
824
+ if (context?.actingUser) {
825
+ return context.actingUser;
826
+ }
827
+
828
+ // Fall back to environment variable
829
+ const envUser = process.env.ACTING_USER;
830
+ if (envUser) {
831
+ return envUser;
680
832
  }
681
833
 
682
834
  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."
835
+ "Authentication required: No acting user specified.\n\n" +
836
+ "Please authenticate with your ACCESS-CI credentials to use this tool. " +
837
+ "If using Claude, add this server as an authenticated connector via Customize > Connectors."
686
838
  );
687
839
  }
688
840
 
689
841
  /**
690
842
  * Create a new announcement via Drupal JSON:API
843
+ *
844
+ * The X-Acting-User header (set by DrupalAuthProvider) tells Drupal which
845
+ * ACCESS user is creating the content. Drupal handles user resolution.
691
846
  */
692
847
  private async createAnnouncement(args: CreateAnnouncementArgs): Promise<CallToolResult> {
693
848
  const auth = this.getDrupalAuth();
694
849
 
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
850
  // Build the JSON:API request body
704
- const requestBody: any = {
851
+ // Note: We don't set the uid relationship - Drupal handles author attribution
852
+ // based on the X-Acting-User header sent with the request
853
+ const requestBody: JsonApiRequestBody = {
705
854
  data: {
706
855
  type: "node--access_news",
707
856
  attributes: {
@@ -709,22 +858,15 @@ Which would you like to do?`,
709
858
  moderation_state: "draft", // Use content moderation workflow
710
859
  body: {
711
860
  value: args.body,
712
- format: "basic_html"
713
- }
861
+ format: "basic_html",
862
+ },
714
863
  },
715
- relationships: {
716
- uid: {
717
- data: {
718
- type: "user--user",
719
- id: userUuid
720
- }
721
- }
722
- }
723
- }
864
+ relationships: {},
865
+ },
724
866
  };
725
867
 
726
868
  // Add optional fields
727
- if (args.summary) {
869
+ if (args.summary && requestBody.data.attributes.body) {
728
870
  requestBody.data.attributes.body.summary = args.summary;
729
871
  }
730
872
 
@@ -732,7 +874,7 @@ Which would you like to do?`,
732
874
  requestBody.data.attributes.field_published_date = args.published_date;
733
875
  } else {
734
876
  // Default to today
735
- requestBody.data.attributes.field_published_date = new Date().toISOString().split('T')[0];
877
+ requestBody.data.attributes.field_published_date = new Date().toISOString().split("T")[0];
736
878
  }
737
879
 
738
880
  if (args.affiliation) {
@@ -740,14 +882,16 @@ Which would you like to do?`,
740
882
  }
741
883
 
742
884
  // Look up tag UUIDs if tags provided
885
+ const unmatchedTags: string[] = [];
743
886
  if (args.tags && args.tags.length > 0) {
744
- const tagUuids = await this.getTagUuidsByName(args.tags);
887
+ const { uuids: tagUuids, unmatched } = await this.resolveTagNames(args.tags);
888
+ unmatchedTags.push(...unmatched);
745
889
  if (tagUuids.length > 0) {
746
- requestBody.data.relationships.field_tags = {
747
- data: tagUuids.map(uuid => ({
890
+ requestBody.data.relationships!.field_tags = {
891
+ data: tagUuids.map((uuid) => ({
748
892
  type: "taxonomy_term--tags",
749
- id: uuid
750
- }))
893
+ id: uuid,
894
+ })),
751
895
  };
752
896
  }
753
897
  }
@@ -756,14 +900,16 @@ Which would you like to do?`,
756
900
  if (args.affinity_group) {
757
901
  const groupUuid = await this.getAffinityGroupUuid(args.affinity_group);
758
902
  if (groupUuid) {
759
- requestBody.data.relationships.field_affinity_group_node = {
903
+ requestBody.data.relationships!.field_affinity_group_node = {
760
904
  data: {
761
905
  type: "node--affinity_group",
762
- id: groupUuid
763
- }
906
+ id: groupUuid,
907
+ },
764
908
  };
765
909
  } else {
766
- throw new Error(`Affinity group not found: ${args.affinity_group}. Use list_affinity_groups to see groups you coordinate.`);
910
+ throw new Error(
911
+ `Affinity group not found: ${args.affinity_group}. Use list_affinity_groups to see groups you coordinate.`
912
+ );
767
913
  }
768
914
  }
769
915
 
@@ -771,28 +917,38 @@ Which would you like to do?`,
771
917
  if (args.external_link) {
772
918
  requestBody.data.attributes.field_news_external_link = {
773
919
  uri: args.external_link.uri,
774
- title: args.external_link.title || ""
920
+ title: args.external_link.title || "",
775
921
  };
776
922
  }
777
923
 
778
924
  // Add where to share (defaults handled by Drupal if not provided)
779
925
  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);
926
+ requestBody.data.attributes.field_choose_where_to_share_this = this.normalizeWhereToShare(
927
+ args.where_to_share
928
+ );
781
929
  }
782
930
 
783
931
  const result = await auth.post("/jsonapi/node/access_news", requestBody);
784
932
 
933
+ const response: Record<string, unknown> = {
934
+ success: true,
935
+ message: "Announcement created (draft status)",
936
+ uuid: result.data?.id,
937
+ title: result.data?.attributes?.title,
938
+ edit_url: `${process.env.DRUPAL_API_URL}/node/${result.data?.attributes?.drupal_internal__nid}/edit`,
939
+ };
940
+
941
+ if (unmatchedTags.length > 0) {
942
+ response.warning = `These tags were not found and were skipped: ${unmatchedTags.join(", ")}. Use suggest_tags to get valid tag names.`;
943
+ }
944
+
785
945
  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
- }]
946
+ content: [
947
+ {
948
+ type: "text" as const,
949
+ text: JSON.stringify(response),
950
+ },
951
+ ],
796
952
  };
797
953
  }
798
954
 
@@ -802,12 +958,12 @@ Which would you like to do?`,
802
958
  private async updateAnnouncement(args: UpdateAnnouncementArgs): Promise<CallToolResult> {
803
959
  const auth = this.getDrupalAuth();
804
960
 
805
- const requestBody: any = {
961
+ const requestBody: JsonApiRequestBody = {
806
962
  data: {
807
963
  type: "node--access_news",
808
964
  id: args.uuid,
809
- attributes: {}
810
- }
965
+ attributes: {},
966
+ },
811
967
  };
812
968
 
813
969
  // Add fields to update
@@ -825,7 +981,7 @@ Which would you like to do?`,
825
981
  requestBody.data.attributes.body = {
826
982
  value: args.body || existingBody,
827
983
  format: "basic_html",
828
- summary: args.summary !== undefined ? args.summary : existingSummary
984
+ summary: args.summary !== undefined ? args.summary : existingSummary,
829
985
  };
830
986
  }
831
987
  // If neither body nor summary provided, don't include body in request at all
@@ -835,17 +991,19 @@ Which would you like to do?`,
835
991
  }
836
992
 
837
993
  // Update tags if provided
994
+ const unmatchedTags: string[] = [];
838
995
  if (args.tags && args.tags.length > 0) {
839
- const tagUuids = await this.getTagUuidsByName(args.tags);
996
+ const { uuids: tagUuids, unmatched } = await this.resolveTagNames(args.tags);
997
+ unmatchedTags.push(...unmatched);
840
998
  if (tagUuids.length > 0) {
841
999
  if (!requestBody.data.relationships) {
842
1000
  requestBody.data.relationships = {};
843
1001
  }
844
1002
  requestBody.data.relationships.field_tags = {
845
- data: tagUuids.map(uuid => ({
1003
+ data: tagUuids.map((uuid) => ({
846
1004
  type: "taxonomy_term--tags",
847
- id: uuid
848
- }))
1005
+ id: uuid,
1006
+ })),
849
1007
  };
850
1008
  }
851
1009
  }
@@ -860,11 +1018,13 @@ Which would you like to do?`,
860
1018
  requestBody.data.relationships.field_affinity_group_node = {
861
1019
  data: {
862
1020
  type: "node--affinity_group",
863
- id: groupUuid
864
- }
1021
+ id: groupUuid,
1022
+ },
865
1023
  };
866
1024
  } else {
867
- throw new Error(`Affinity group not found: ${args.affinity_group}. Use list_affinity_groups to see groups you coordinate.`);
1025
+ throw new Error(
1026
+ `Affinity group not found: ${args.affinity_group}. Use list_affinity_groups to see groups you coordinate.`
1027
+ );
868
1028
  }
869
1029
  }
870
1030
 
@@ -872,30 +1032,40 @@ Which would you like to do?`,
872
1032
  if (args.external_link) {
873
1033
  requestBody.data.attributes.field_news_external_link = {
874
1034
  uri: args.external_link.uri,
875
- title: args.external_link.title || ""
1035
+ title: args.external_link.title || "",
876
1036
  };
877
1037
  }
878
1038
 
879
1039
  // Update where to share if provided
880
1040
  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);
1041
+ requestBody.data.attributes.field_choose_where_to_share_this = this.normalizeWhereToShare(
1042
+ args.where_to_share
1043
+ );
882
1044
  }
883
1045
 
884
1046
  const result = await auth.patch(`/jsonapi/node/access_news/${args.uuid}`, requestBody);
885
1047
  const nid = result.data?.attributes?.drupal_internal__nid;
886
1048
  const baseUrl = process.env.DRUPAL_API_URL;
887
1049
 
1050
+ const response: Record<string, unknown> = {
1051
+ success: true,
1052
+ message: "Announcement updated",
1053
+ uuid: result.data?.id,
1054
+ title: result.data?.attributes?.title,
1055
+ edit_url: nid ? `${baseUrl}/node/${nid}/edit` : null,
1056
+ };
1057
+
1058
+ if (unmatchedTags.length > 0) {
1059
+ response.warning = `These tags were not found and were skipped: ${unmatchedTags.join(", ")}. Use suggest_tags to get valid tag names.`;
1060
+ }
1061
+
888
1062
  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
- }]
1063
+ content: [
1064
+ {
1065
+ type: "text" as const,
1066
+ text: JSON.stringify(response),
1067
+ },
1068
+ ],
899
1069
  };
900
1070
  }
901
1071
 
@@ -906,13 +1076,16 @@ Which would you like to do?`,
906
1076
  // Enforce confirmation parameter
907
1077
  if (!args.confirmed) {
908
1078
  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
- }]
1079
+ content: [
1080
+ {
1081
+ type: "text" as const,
1082
+ text: JSON.stringify({
1083
+ error:
1084
+ "Deletion requires explicit confirmation. You must show the announcement title and status to the user and get explicit confirmation before setting confirmed=true.",
1085
+ uuid: args.uuid,
1086
+ }),
1087
+ },
1088
+ ],
916
1089
  };
917
1090
  }
918
1091
 
@@ -921,38 +1094,40 @@ Which would you like to do?`,
921
1094
  await auth.delete(`/jsonapi/node/access_news/${args.uuid}`);
922
1095
 
923
1096
  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
- }]
1097
+ content: [
1098
+ {
1099
+ type: "text" as const,
1100
+ text: JSON.stringify({
1101
+ success: true,
1102
+ message: "Announcement deleted",
1103
+ uuid: args.uuid,
1104
+ }),
1105
+ },
1106
+ ],
932
1107
  };
933
1108
  }
934
1109
 
935
1110
  /**
936
- * Get announcements created by the acting user
1111
+ * Get announcements created by the acting user.
1112
+ *
1113
+ * Uses a Drupal Views page display exposed via jsonapi_views at
1114
+ * /jsonapi/views/mcp_my_announcements/page_1.
1115
+ * The JsonApiViewsUserParameterSubscriber resolves X-Acting-User header to uid
1116
+ * and injects it as the contextual filter argument, so no user UUID lookup is needed.
937
1117
  */
938
1118
  private async getMyAnnouncements(args: GetMyAnnouncementsArgs): Promise<CallToolResult> {
939
1119
  const auth = this.getDrupalAuth();
940
1120
 
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
- }
1121
+ // Ensure acting user is set (will throw if not available)
1122
+ this.getActingUserAccessId();
948
1123
 
949
1124
  const limit = args.limit || 25;
950
1125
  const result = await auth.get(
951
- `/jsonapi/node/access_news?filter[uid.id]=${userUuid}&page[limit]=${limit}&sort=-created`
1126
+ `/jsonapi/views/mcp_my_announcements/page_1?page[limit]=${limit}`
952
1127
  );
953
1128
 
954
1129
  const baseUrl = process.env.DRUPAL_API_URL;
955
- const announcements = (result.data || []).map((item: any) => {
1130
+ const announcements = (result.data || []).map((item: JsonApiResourceItem) => {
956
1131
  const nid = item.attributes?.drupal_internal__nid;
957
1132
  return {
958
1133
  uuid: item.id,
@@ -961,21 +1136,25 @@ Which would you like to do?`,
961
1136
  status: item.attributes?.status ? "published" : "draft",
962
1137
  created: item.attributes?.created,
963
1138
  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
1139
+ summary:
1140
+ item.attributes?.body?.summary ||
1141
+ (item.attributes?.body?.value
1142
+ ? item.attributes.body.value.replace(/<[^>]*>/g, "").substring(0, 200) + "..."
1143
+ : ""),
1144
+ edit_url: nid ? `${baseUrl}/node/${nid}/edit` : null,
968
1145
  };
969
1146
  });
970
1147
 
971
1148
  return {
972
- content: [{
973
- type: "text" as const,
974
- text: JSON.stringify({
975
- total: announcements.length,
976
- items: announcements
977
- })
978
- }]
1149
+ content: [
1150
+ {
1151
+ type: "text" as const,
1152
+ text: JSON.stringify({
1153
+ total: announcements.length,
1154
+ items: announcements,
1155
+ }),
1156
+ },
1157
+ ],
979
1158
  };
980
1159
  }
981
1160
 
@@ -984,73 +1163,120 @@ Which would you like to do?`,
984
1163
  // ============================================================================
985
1164
 
986
1165
  /**
987
- * Get a user's UUID by their Drupal user ID (internal helper)
1166
+ * Get announcement context - tags, affinity groups, and options for creating announcements.
1167
+ *
1168
+ * Uses a Drupal Views page display exposed via jsonapi_views at
1169
+ * /jsonapi/views/mcp_my_affinity_groups/page_1 for affinity groups lookup.
1170
+ * The JsonApiViewsUserParameterSubscriber resolves X-Acting-User header to uid,
1171
+ * so no user UUID lookup is needed.
988
1172
  */
989
- private async getUserUuidByUid(uid: number): Promise<string | null> {
1173
+ private async getAnnouncementContext(): Promise<CallToolResult> {
990
1174
  const auth = this.getDrupalAuth();
991
- const result = await auth.get(`/jsonapi/user/user?filter[drupal_internal__uid]=${uid}`);
992
1175
 
993
- if (!result.data || result.data.length === 0) {
994
- return null;
995
- }
1176
+ // Ensure acting user is set (will throw if not available)
1177
+ this.getActingUserAccessId();
1178
+
1179
+ // Fetch affinity groups the user coordinates.
1180
+ // Tags are NOT fetched here — use suggest_tags tool after the user provides content.
1181
+ const groupsResult = await auth.get("/jsonapi/views/mcp_my_affinity_groups/page_1");
1182
+
1183
+ const affinityGroups = (groupsResult.data || []).map((item: JsonApiResourceItem) => ({
1184
+ id: item.attributes?.field_group_id,
1185
+ uuid: item.id,
1186
+ name: item.attributes?.title,
1187
+ category: item.attributes?.field_affinity_group_category,
1188
+ }));
1189
+
1190
+ const isCoordinator = affinityGroups.length > 0;
996
1191
 
997
- return result.data[0].id;
1192
+ return {
1193
+ content: [
1194
+ {
1195
+ type: "text" as const,
1196
+ text: JSON.stringify({
1197
+ affinity_groups: affinityGroups,
1198
+ is_coordinator: isCoordinator,
1199
+ affiliations: ["ACCESS Collaboration", "Community"],
1200
+ where_to_share_options: [
1201
+ { value: "Announcements page", description: "Public announcements listing" },
1202
+ { value: "Bi-Weekly Digest", description: "ACCESS Support bi-weekly email digest" },
1203
+ {
1204
+ value: "Affinity Group page",
1205
+ description: "Your affinity group's page (coordinators only)",
1206
+ },
1207
+ {
1208
+ value: "Email to Affinity Group",
1209
+ description: "Direct email to affinity group members (coordinators only)",
1210
+ },
1211
+ ],
1212
+ guidance: isCoordinator
1213
+ ? "User is an affinity group coordinator. Ask about affinity_group association and where_to_share preferences."
1214
+ : "User is not a coordinator. Standard announcement fields apply.",
1215
+ }),
1216
+ },
1217
+ ],
1218
+ };
998
1219
  }
999
1220
 
1000
1221
  /**
1001
- * Get announcement context - tags, affinity groups, and options for creating announcements
1222
+ * Suggest tags for announcement content using Drupal's AI tag suggestion service.
1002
1223
  */
1003
- private async getAnnouncementContext(): Promise<CallToolResult> {
1004
- const auth = this.getDrupalAuth();
1224
+ private static readonly JSON_HEADERS = {
1225
+ "Content-Type": "application/json",
1226
+ Accept: "application/json",
1227
+ };
1005
1228
 
1006
- // Get the acting user's UUID
1007
- const actingUserUid = this.getActingUserUid();
1008
- const userUuid = await this.getUserUuidByUid(actingUserUid);
1229
+ private async suggestTags(args: { text: string; limit?: number }): Promise<CallToolResult> {
1230
+ if (!args.text || args.text.length < 100) {
1231
+ return this.errorResponse("Text must be at least 100 characters for tag suggestions.");
1232
+ }
1009
1233
 
1010
- if (!userUuid) {
1011
- throw new Error(`Could not find user with UID ${actingUserUid}`);
1234
+ const auth = this.getDrupalAuth();
1235
+ const limit = args.limit || 6;
1236
+ const result = await auth.post("/api/suggest-tags", {
1237
+ text: args.text,
1238
+ limit,
1239
+ }, AnnouncementsServer.JSON_HEADERS);
1240
+
1241
+ if (result.tags) {
1242
+ const tags = result.tags.slice(0, limit);
1243
+ return {
1244
+ content: [{
1245
+ type: "text" as const,
1246
+ text: JSON.stringify({
1247
+ suggested_tags: tags.map((t: { name: string; uuid: string }) => t.name),
1248
+ tag_details: tags,
1249
+ }),
1250
+ }],
1251
+ };
1012
1252
  }
1013
1253
 
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
- ]);
1254
+ return this.errorResponse(result.error || "Tag suggestion failed");
1255
+ }
1019
1256
 
1020
- const tags = (tagsResult.data || []).map((item: any) => ({
1021
- name: item.attributes?.name,
1022
- uuid: item.id
1023
- }));
1257
+ /**
1258
+ * Generate a summary for announcement content using Drupal's AI summary service.
1259
+ */
1260
+ private async suggestSummary(args: { text: string }): Promise<CallToolResult> {
1261
+ if (!args.text || args.text.length < 100) {
1262
+ return this.errorResponse("Text must be at least 100 characters for summary generation.");
1263
+ }
1024
1264
 
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
- }));
1265
+ const auth = this.getDrupalAuth();
1266
+ const result = await auth.post("/api/suggest-summary", {
1267
+ text: args.text,
1268
+ }, AnnouncementsServer.JSON_HEADERS);
1031
1269
 
1032
- const isCoordinator = affinityGroups.length > 0;
1270
+ if (result.summary) {
1271
+ return {
1272
+ content: [{
1273
+ type: "text" as const,
1274
+ text: JSON.stringify({ summary: result.summary }),
1275
+ }],
1276
+ };
1277
+ }
1033
1278
 
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
- };
1279
+ return this.errorResponse(result.error || "Summary generation failed");
1054
1280
  }
1055
1281
 
1056
1282
  /**
@@ -1098,19 +1324,19 @@ Which would you like to do?`,
1098
1324
  "email to affinity group": "email_to_your_affinity_group",
1099
1325
  "email to your affinity group": "email_to_your_affinity_group",
1100
1326
  // 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",
1327
+ on_the_announcements_page: "on_the_announcements_page",
1328
+ in_the_access_support_bi_weekly_digest: "in_the_access_support_bi_weekly_digest",
1329
+ on_your_affinity_group_page: "on_your_affinity_group_page",
1330
+ email_to_your_affinity_group: "email_to_your_affinity_group",
1105
1331
  };
1106
1332
 
1107
1333
  private normalizeWhereToShare(values: string[]): string[] {
1108
- return values.map(v => {
1334
+ return values.map((v) => {
1109
1335
  const normalized = AnnouncementsServer.WHERE_TO_SHARE_MAP[v.toLowerCase()];
1110
1336
  if (!normalized) {
1111
1337
  throw new Error(
1112
1338
  `Invalid where_to_share value: "${v}". ` +
1113
- `Valid options: 'Announcements page', 'Bi-Weekly Digest', 'Affinity Group page', 'Email to Affinity Group'`
1339
+ `Valid options: 'Announcements page', 'Bi-Weekly Digest', 'Affinity Group page', 'Email to Affinity Group'`
1114
1340
  );
1115
1341
  }
1116
1342
  return normalized;
@@ -1129,36 +1355,64 @@ Which would you like to do?`,
1129
1355
  */
1130
1356
  private async populateTagCache(): Promise<void> {
1131
1357
  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);
1358
+ try {
1359
+ this.tagCache.clear();
1360
+ let url: string | null = "/jsonapi/taxonomy_term/tags?page[limit]=50";
1361
+ let pageCount = 0;
1362
+ const MAX_PAGES = 100;
1363
+
1364
+ while (url && pageCount < MAX_PAGES) {
1365
+ pageCount++;
1366
+ const result = await auth.get(url);
1367
+ for (const item of result.data || []) {
1368
+ const name = item.attributes?.name?.toLowerCase();
1369
+ if (name && item.id) {
1370
+ this.tagCache.set(name, item.id);
1371
+ }
1372
+ }
1373
+ // Follow pagination links
1374
+ const nextHref = result.links?.next?.href;
1375
+ if (nextHref) {
1376
+ try {
1377
+ const parsed = new URL(nextHref);
1378
+ url = parsed.pathname + parsed.search;
1379
+ } catch {
1380
+ url = nextHref; // Already a relative path
1381
+ }
1382
+ } else {
1383
+ url = null;
1384
+ }
1139
1385
  }
1140
- }
1141
1386
 
1142
- this.tagCacheExpiry = new Date(Date.now() + AnnouncementsServer.TAG_CACHE_TTL_MS);
1387
+ this.logger.info("Tag cache populated", { count: this.tagCache.size });
1388
+ this.tagCacheExpiry = new Date(Date.now() + AnnouncementsServer.TAG_CACHE_TTL_MS);
1389
+ } catch (error) {
1390
+ const msg = error instanceof Error ? error.message : String(error);
1391
+ this.logger.error("Failed to populate tag cache", { error: msg });
1392
+ this.tagCache.clear(); // Don't leave partial data
1393
+ }
1143
1394
  }
1144
1395
 
1145
1396
  /**
1146
1397
  * Get tag UUIDs by their names (with caching)
1147
1398
  */
1148
- private async getTagUuidsByName(tagNames: string[]): Promise<string[]> {
1399
+ private async resolveTagNames(tagNames: string[]): Promise<{ uuids: string[]; unmatched: string[] }> {
1149
1400
  // Ensure cache is populated
1150
1401
  if (!this.isTagCacheValid()) {
1151
1402
  await this.populateTagCache();
1152
1403
  }
1153
1404
 
1154
1405
  const uuids: string[] = [];
1406
+ const unmatched: string[] = [];
1155
1407
  for (const name of tagNames) {
1156
1408
  const uuid = this.tagCache.get(name.toLowerCase());
1157
1409
  if (uuid) {
1158
1410
  uuids.push(uuid);
1411
+ } else {
1412
+ unmatched.push(name);
1159
1413
  }
1160
1414
  }
1161
1415
 
1162
- return uuids;
1416
+ return { uuids, unmatched };
1163
1417
  }
1164
1418
  }