@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/dist/server.js CHANGED
@@ -1,4 +1,4 @@
1
- import { BaseAccessServer, DrupalAuthProvider } from "@access-mcp/shared";
1
+ import { BaseAccessServer, DrupalAuthProvider, getRequestContext, } from "@access-mcp/shared";
2
2
  import { createRequire } from "module";
3
3
  const require = createRequire(import.meta.url);
4
4
  const { version } = require("../package.json");
@@ -11,11 +11,17 @@ export class AnnouncementsServer extends BaseAccessServer {
11
11
  tagCacheExpiry;
12
12
  static TAG_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
13
13
  constructor() {
14
- super("access-announcements", version, "https://support.access-ci.org");
14
+ super("access-announcements", version, "https://support.access-ci.org", {
15
+ requireApiKey: true,
16
+ });
15
17
  }
16
18
  /**
17
19
  * Get or create the Drupal auth provider for JSON:API write operations.
18
20
  * Requires DRUPAL_API_URL, DRUPAL_USERNAME, and DRUPAL_PASSWORD env vars.
21
+ *
22
+ * Acting user is determined in priority order:
23
+ * 1. X-Acting-User header (from request context)
24
+ * 2. ACTING_USER environment variable (fallback)
19
25
  */
20
26
  getDrupalAuth() {
21
27
  if (!this.drupalAuth) {
@@ -27,6 +33,12 @@ export class AnnouncementsServer extends BaseAccessServer {
27
33
  }
28
34
  this.drupalAuth = new DrupalAuthProvider(baseUrl, username, password);
29
35
  }
36
+ // Update acting user from request context (takes priority) or env var (fallback)
37
+ const context = getRequestContext();
38
+ const actingUser = context?.actingUser || process.env.ACTING_USER;
39
+ if (actingUser) {
40
+ this.drupalAuth.setActingUser(actingUser);
41
+ }
30
42
  return this.drupalAuth;
31
43
  }
32
44
  getTools() {
@@ -40,24 +52,24 @@ export class AnnouncementsServer extends BaseAccessServer {
40
52
  properties: {
41
53
  query: {
42
54
  type: "string",
43
- description: "Full-text search across title, body, and summary"
55
+ description: "Full-text search across title, body, and summary",
44
56
  },
45
57
  tags: {
46
58
  type: "string",
47
- description: "Filter by topics: gpu, ml, hpc"
59
+ description: "Filter by topics: gpu, ml, hpc",
48
60
  },
49
61
  date: {
50
62
  type: "string",
51
63
  description: "Time period filter: today, this_week (last 7 days), this_month (last 30 days), past (last year)",
52
- enum: ["today", "this_week", "this_month", "past"]
64
+ enum: ["today", "this_week", "this_month", "past"],
53
65
  },
54
66
  limit: {
55
67
  type: "number",
56
68
  description: "Max results (default: 25)",
57
- default: 25
58
- }
59
- }
60
- }
69
+ default: 25,
70
+ },
71
+ },
72
+ },
61
73
  },
62
74
  // CRUD operations (new)
63
75
  {
@@ -65,12 +77,12 @@ export class AnnouncementsServer extends BaseAccessServer {
65
77
  description: `Create a new ACCESS announcement (saved as draft for staff review).
66
78
 
67
79
  BEFORE CALLING:
68
- 1. Call get_announcement_context first to get tags, check coordinator status, and tailor the conversation.
80
+ 1. Call get_announcement_context to check coordinator status.
69
81
  2. Gather information - either conversationally OR by parsing pasted content:
70
82
  - title (required): Clear, specific headline
71
83
  - body (required): Full content with details, dates, links. HTML supported.
72
84
  - summary (required): 1-2 sentence teaser for listings
73
- - tags (recommended): 1-6 tags help users find it
85
+ - tags (required): Call suggest_tags with the body text to get tag suggestions, then include them
74
86
  - affiliation (optional): "ACCESS Collaboration" or "Community" (default)
75
87
  - external_link (if relevant): URL and link text for external references
76
88
  3. IF user is a coordinator (check get_announcement_context response):
@@ -81,19 +93,22 @@ WORKFLOW:
81
93
  If user pastes content (event description, draft text, etc.):
82
94
  1. Call get_announcement_context
83
95
  2. Parse the pasted content to extract title, body, dates, links, etc.
84
- 3. Match content to available tags from context
85
- 4. Ask only about missing/unclear fields
86
- 5. If coordinator: ask about affinity group and where to share
87
- 6. Show preview and get confirmation
88
- 7. Create the announcement
96
+ 3. Call suggest_tags with the body text — include returned tag names in the tags parameter
97
+ 4. Call suggest_summary with the body text if no summary provided
98
+ 5. Ask only about missing/unclear fields
99
+ 6. If coordinator: ask about affinity group and where to share
100
+ 7. Show preview (including tags) and get confirmation
101
+ 8. Create the announcement with ALL fields including tags
89
102
 
90
103
  If user describes what they want conversationally:
91
104
  1. Call get_announcement_context
92
105
  2. Guide them through providing title, body, summary
93
- 3. Suggest relevant tags from context
106
+ 3. Call suggest_tags with the body text — include returned tag names in the tags parameter
94
107
  4. If coordinator: ask about affinity group and where to share
95
- 5. Show preview and get confirmation
96
- 6. Create the announcement
108
+ 5. Show preview (including tags) and get confirmation
109
+ 6. Create the announcement with ALL fields including tags
110
+
111
+ IMPORTANT: Always pass the tags parameter when creating. Tags from suggest_tags are tag names (strings) — pass them as the tags array.
97
112
 
98
113
  PREVIEW FORMAT (show before creating):
99
114
  ---
@@ -118,51 +133,51 @@ ALWAYS display the edit_url to the user so they can review their draft in Drupal
118
133
  properties: {
119
134
  title: {
120
135
  type: "string",
121
- description: "Announcement title - clear and specific, under 100 characters"
136
+ description: "Announcement title - clear and specific, under 100 characters",
122
137
  },
123
138
  body: {
124
139
  type: "string",
125
- description: "Full announcement content. HTML allowed (basic_html format: <p>, <a>, <strong>, <em>, <ul>, <li>)"
140
+ description: "Full announcement content. HTML allowed (basic_html format: <p>, <a>, <strong>, <em>, <ul>, <li>)",
126
141
  },
127
142
  summary: {
128
143
  type: "string",
129
- description: "Brief teaser (1-2 sentences) shown in announcement listings. Required."
144
+ description: "Brief teaser (1-2 sentences) shown in announcement listings. Required.",
130
145
  },
131
146
  published_date: {
132
147
  type: "string",
133
- description: "When to display (YYYY-MM-DD). Defaults to today."
148
+ description: "When to display (YYYY-MM-DD). Defaults to today.",
134
149
  },
135
150
  tags: {
136
151
  type: "array",
137
152
  items: { type: "string" },
138
- description: "Tag names (1-6 required for publication). Use list_available_tags to find valid tags."
153
+ description: 'Tag names as strings, e.g. ["ai", "machine-learning", "gpu"]. Get these from the "name" field in suggest_tags response.',
139
154
  },
140
155
  affiliation: {
141
156
  type: "string",
142
157
  description: "Source of announcement",
143
- enum: ["ACCESS Collaboration", "Community"]
158
+ enum: ["ACCESS Collaboration", "Community"],
144
159
  },
145
160
  affinity_group: {
146
161
  type: "string",
147
- description: "Affinity group name or UUID. User must be coordinator of the group. Use list_affinity_groups to find valid groups."
162
+ description: "Affinity group name or UUID. User must be coordinator of the group. Use list_affinity_groups to find valid groups.",
148
163
  },
149
164
  external_link: {
150
165
  type: "object",
151
166
  description: "External link related to this announcement",
152
167
  properties: {
153
168
  uri: { type: "string", description: "URL (must include https://)" },
154
- title: { type: "string", description: "Link text to display" }
169
+ title: { type: "string", description: "Link text to display" },
155
170
  },
156
- required: ["uri"]
171
+ required: ["uri"],
157
172
  },
158
173
  where_to_share: {
159
174
  type: "array",
160
175
  items: { type: "string" },
161
- 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'"
162
- }
176
+ 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'",
177
+ },
163
178
  },
164
- required: ["title", "body", "summary"]
165
- }
179
+ required: ["title", "body", "summary"],
180
+ },
166
181
  },
167
182
  {
168
183
  name: "update_announcement",
@@ -181,50 +196,50 @@ ALWAYS display the edit_url to the user so they can review changes in Drupal.`,
181
196
  properties: {
182
197
  uuid: {
183
198
  type: "string",
184
- description: "UUID of the announcement (get from get_my_announcements)"
199
+ description: "UUID of the announcement (get from get_my_announcements)",
185
200
  },
186
201
  title: {
187
202
  type: "string",
188
- description: "New title (only if changing)"
203
+ description: "New title (only if changing)",
189
204
  },
190
205
  body: {
191
206
  type: "string",
192
- description: "New body content (only if changing)"
207
+ description: "New body content (only if changing)",
193
208
  },
194
209
  summary: {
195
210
  type: "string",
196
- description: "New summary (only if changing)"
211
+ description: "New summary (only if changing)",
197
212
  },
198
213
  published_date: {
199
214
  type: "string",
200
- description: "New publication date YYYY-MM-DD (only if changing)"
215
+ description: "New publication date YYYY-MM-DD (only if changing)",
201
216
  },
202
217
  tags: {
203
218
  type: "array",
204
219
  items: { type: "string" },
205
- description: "New tags - replaces ALL existing tags (only if changing)"
220
+ description: "New tags - replaces ALL existing tags (only if changing)",
206
221
  },
207
222
  affinity_group: {
208
223
  type: "string",
209
- description: "Affinity group name or UUID. User must be coordinator."
224
+ description: "Affinity group name or UUID. User must be coordinator.",
210
225
  },
211
226
  external_link: {
212
227
  type: "object",
213
228
  description: "External link related to this announcement",
214
229
  properties: {
215
230
  uri: { type: "string", description: "URL (must include https://)" },
216
- title: { type: "string", description: "Link text to display" }
231
+ title: { type: "string", description: "Link text to display" },
217
232
  },
218
- required: ["uri"]
233
+ required: ["uri"],
219
234
  },
220
235
  where_to_share: {
221
236
  type: "array",
222
237
  items: { type: "string" },
223
- description: "Where to share the announcement. Options: 'Announcements page', 'Bi-Weekly Digest', 'Affinity Group page', 'Email to Affinity Group'"
224
- }
238
+ description: "Where to share the announcement. Options: 'Announcements page', 'Bi-Weekly Digest', 'Affinity Group page', 'Email to Affinity Group'",
239
+ },
225
240
  },
226
- required: ["uuid"]
227
- }
241
+ required: ["uuid"],
242
+ },
228
243
  },
229
244
  {
230
245
  name: "delete_announcement",
@@ -248,15 +263,15 @@ Returns: {success, uuid}`,
248
263
  properties: {
249
264
  uuid: {
250
265
  type: "string",
251
- description: "UUID of the announcement to delete"
266
+ description: "UUID of the announcement to delete",
252
267
  },
253
268
  confirmed: {
254
269
  type: "boolean",
255
- description: "Set to true only after user has explicitly confirmed deletion of THIS specific announcement. Required."
256
- }
270
+ description: "Set to true only after user has explicitly confirmed deletion of THIS specific announcement. Required.",
271
+ },
257
272
  },
258
- required: ["uuid", "confirmed"]
259
- }
273
+ required: ["uuid", "confirmed"],
274
+ },
260
275
  },
261
276
  {
262
277
  name: "get_my_announcements",
@@ -275,28 +290,59 @@ Use this to:
275
290
  limit: {
276
291
  type: "number",
277
292
  description: "Max results (default: 25)",
278
- default: 25
279
- }
280
- }
281
- }
293
+ default: 25,
294
+ },
295
+ },
296
+ },
282
297
  },
283
298
  {
284
299
  name: "get_announcement_context",
285
300
  description: `Get user context and options BEFORE creating an announcement. Call this first.
286
301
 
287
302
  Returns:
288
- - tags: Available tags for announcements [{name, uuid}]
289
303
  - affinity_groups: Groups the user coordinates (empty if not a coordinator)
290
304
  - is_coordinator: Boolean - if true, ask about affinity_group and where_to_share
291
305
  - affiliations: Available affiliation options
292
306
  - where_to_share_options: Available sharing options (only relevant for coordinators)
293
307
 
294
- Use this to tailor the announcement creation conversation based on the user's role.`,
308
+ Does NOT return tags use suggest_tags after the user provides content.`,
295
309
  inputSchema: {
296
310
  type: "object",
297
- properties: {}
298
- }
299
- }
311
+ properties: {},
312
+ },
313
+ },
314
+ {
315
+ name: "suggest_tags",
316
+ 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.`,
317
+ inputSchema: {
318
+ type: "object",
319
+ properties: {
320
+ text: {
321
+ type: "string",
322
+ description: "The announcement body text to analyze for tag suggestions. Must be at least 100 characters.",
323
+ },
324
+ limit: {
325
+ type: "number",
326
+ description: "Maximum number of tags to suggest (default: 6)",
327
+ },
328
+ },
329
+ required: ["text"],
330
+ },
331
+ },
332
+ {
333
+ name: "suggest_summary",
334
+ 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.`,
335
+ inputSchema: {
336
+ type: "object",
337
+ properties: {
338
+ text: {
339
+ type: "string",
340
+ description: "The announcement body text to summarize. Must be at least 100 characters.",
341
+ },
342
+ },
343
+ required: ["text"],
344
+ },
345
+ },
300
346
  ];
301
347
  }
302
348
  getResources() {
@@ -305,8 +351,8 @@ Use this to tailor the announcement creation conversation based on the user's ro
305
351
  uri: "accessci://announcements",
306
352
  name: "ACCESS Support Announcements",
307
353
  description: "Recent announcements and notifications from ACCESS support",
308
- mimeType: "application/json"
309
- }
354
+ mimeType: "application/json",
355
+ },
310
356
  ];
311
357
  }
312
358
  getPrompts() {
@@ -349,9 +395,9 @@ Use this to tailor the announcement creation conversation based on the user's ro
349
395
  role: "assistant",
350
396
  content: {
351
397
  type: "text",
352
- text: `I'll help you create an ACCESS announcement. Let me first check your available options and coordinator status.
398
+ text: `I'll help you create an ACCESS announcement. Let me check your options first.
353
399
 
354
- First, I'll call \`get_announcement_context\` to see what tags are available and whether you coordinate any affinity groups.
400
+ Once you provide the content, I'll suggest relevant tags and generate a summary automatically.
355
401
 
356
402
  **Required Information:**
357
403
 
@@ -449,6 +495,10 @@ Which would you like to do?`,
449
495
  // Helper operations
450
496
  case "get_announcement_context":
451
497
  return await this.getAnnouncementContext();
498
+ case "suggest_tags":
499
+ return await this.suggestTags(args);
500
+ case "suggest_summary":
501
+ return await this.suggestSummary(args);
452
502
  default:
453
503
  return this.errorResponse(`Unknown tool: ${name}`);
454
504
  }
@@ -468,9 +518,9 @@ Which would you like to do?`,
468
518
  {
469
519
  uri,
470
520
  mimeType: "application/json",
471
- text: JSON.stringify(announcements, null, 2)
472
- }
473
- ]
521
+ text: JSON.stringify(announcements, null, 2),
522
+ },
523
+ ],
474
524
  };
475
525
  }
476
526
  catch (error) {
@@ -480,9 +530,9 @@ Which would you like to do?`,
480
530
  {
481
531
  uri,
482
532
  mimeType: "text/plain",
483
- text: `Error loading announcements: ${message}`
484
- }
485
- ]
533
+ text: `Error loading announcements: ${message}`,
534
+ },
535
+ ],
486
536
  };
487
537
  }
488
538
  }
@@ -514,7 +564,7 @@ Which would you like to do?`,
514
564
  today: { start: "today" },
515
565
  this_week: { start: "-1 week", end: "now" },
516
566
  this_month: { start: "-1 month", end: "now" },
517
- past: { start: "-1 year", end: "now" }
567
+ past: { start: "-1 year", end: "now" },
518
568
  };
519
569
  if (filters.date && dateMap[filters.date]) {
520
570
  const dateRange = dateMap[filters.date];
@@ -535,61 +585,73 @@ Which would you like to do?`,
535
585
  return this.enhanceAnnouncements(announcements);
536
586
  }
537
587
  enhanceAnnouncements(rawAnnouncements) {
538
- return rawAnnouncements.map(announcement => ({
588
+ return rawAnnouncements.map((announcement) => ({
539
589
  ...announcement,
540
590
  // Tags come as comma-separated string from public API, convert to array
541
591
  tags: Array.isArray(announcement.tags)
542
592
  ? announcement.tags
543
- : (typeof announcement.tags === 'string' && announcement.tags.trim()
544
- ? announcement.tags.split(',').map((t) => t.trim())
545
- : []),
546
- summary: announcement.summary || (announcement.body ? announcement.body.replace(/<[^>]*>/g, '').substring(0, 200) + '...' : '')
593
+ : typeof announcement.tags === "string" && announcement.tags.trim()
594
+ ? announcement.tags.split(",").map((t) => t.trim())
595
+ : [],
596
+ summary: announcement.summary ||
597
+ (announcement.body
598
+ ? announcement.body.replace(/<[^>]*>/g, "").substring(0, 200) + "..."
599
+ : ""),
547
600
  }));
548
601
  }
549
602
  async searchAnnouncements(filters) {
550
603
  const announcements = await this.fetchAnnouncements(filters);
551
604
  const limited = filters.limit ? announcements.slice(0, filters.limit) : announcements;
552
605
  return {
553
- content: [{
606
+ content: [
607
+ {
554
608
  type: "text",
555
609
  text: JSON.stringify({
556
610
  total: announcements.length,
557
- items: limited
558
- })
559
- }]
611
+ items: limited,
612
+ }),
613
+ },
614
+ ],
560
615
  };
561
616
  }
562
617
  // ============================================================================
563
618
  // CRUD Operations (new)
564
619
  // ============================================================================
565
620
  /**
566
- * Get the acting user's UID from environment variable.
567
- * This identifies who the announcement should be attributed to.
621
+ * Get the acting user's ACCESS ID for content attribution.
622
+ *
623
+ * Priority order:
624
+ * 1. X-Acting-User header (from request context)
625
+ * 2. ACTING_USER environment variable (fallback)
626
+ *
627
+ * Returns the ACCESS ID (e.g., "username@access-ci.org")
568
628
  */
569
- getActingUserUid() {
570
- const envUid = process.env.ACTING_USER_UID;
571
- if (envUid) {
572
- const parsed = parseInt(envUid, 10);
573
- if (!isNaN(parsed)) {
574
- return parsed;
575
- }
629
+ getActingUserAccessId() {
630
+ // Try request context first
631
+ const context = getRequestContext();
632
+ if (context?.actingUser) {
633
+ return context.actingUser;
634
+ }
635
+ // Fall back to environment variable
636
+ const envUser = process.env.ACTING_USER;
637
+ if (envUser) {
638
+ return envUser;
576
639
  }
577
- throw new Error("Cannot create announcement: No acting user specified.\n\n" +
578
- "The ACTING_USER_UID environment variable must be set to the Drupal user ID " +
579
- "of the person creating the announcement. This should be configured by the chat system.");
640
+ throw new Error("Authentication required: No acting user specified.\n\n" +
641
+ "Please authenticate with your ACCESS-CI credentials to use this tool. " +
642
+ "If using Claude, add this server as an authenticated connector via Customize > Connectors.");
580
643
  }
581
644
  /**
582
645
  * Create a new announcement via Drupal JSON:API
646
+ *
647
+ * The X-Acting-User header (set by DrupalAuthProvider) tells Drupal which
648
+ * ACCESS user is creating the content. Drupal handles user resolution.
583
649
  */
584
650
  async createAnnouncement(args) {
585
651
  const auth = this.getDrupalAuth();
586
- // Get the acting user - required for proper attribution
587
- const actingUserUid = this.getActingUserUid();
588
- const userUuid = await this.getUserUuidByUid(actingUserUid);
589
- if (!userUuid) {
590
- throw new Error(`Could not find user with UID ${actingUserUid}. Verify the ACTING_USER_UID is correct.`);
591
- }
592
652
  // Build the JSON:API request body
653
+ // Note: We don't set the uid relationship - Drupal handles author attribution
654
+ // based on the X-Acting-User header sent with the request
593
655
  const requestBody = {
594
656
  data: {
595
657
  type: "node--access_news",
@@ -598,21 +660,14 @@ Which would you like to do?`,
598
660
  moderation_state: "draft", // Use content moderation workflow
599
661
  body: {
600
662
  value: args.body,
601
- format: "basic_html"
602
- }
663
+ format: "basic_html",
664
+ },
603
665
  },
604
- relationships: {
605
- uid: {
606
- data: {
607
- type: "user--user",
608
- id: userUuid
609
- }
610
- }
611
- }
612
- }
666
+ relationships: {},
667
+ },
613
668
  };
614
669
  // Add optional fields
615
- if (args.summary) {
670
+ if (args.summary && requestBody.data.attributes.body) {
616
671
  requestBody.data.attributes.body.summary = args.summary;
617
672
  }
618
673
  if (args.published_date) {
@@ -620,20 +675,22 @@ Which would you like to do?`,
620
675
  }
621
676
  else {
622
677
  // Default to today
623
- requestBody.data.attributes.field_published_date = new Date().toISOString().split('T')[0];
678
+ requestBody.data.attributes.field_published_date = new Date().toISOString().split("T")[0];
624
679
  }
625
680
  if (args.affiliation) {
626
681
  requestBody.data.attributes.field_affiliation = args.affiliation;
627
682
  }
628
683
  // Look up tag UUIDs if tags provided
684
+ const unmatchedTags = [];
629
685
  if (args.tags && args.tags.length > 0) {
630
- const tagUuids = await this.getTagUuidsByName(args.tags);
686
+ const { uuids: tagUuids, unmatched } = await this.resolveTagNames(args.tags);
687
+ unmatchedTags.push(...unmatched);
631
688
  if (tagUuids.length > 0) {
632
689
  requestBody.data.relationships.field_tags = {
633
- data: tagUuids.map(uuid => ({
690
+ data: tagUuids.map((uuid) => ({
634
691
  type: "taxonomy_term--tags",
635
- id: uuid
636
- }))
692
+ id: uuid,
693
+ })),
637
694
  };
638
695
  }
639
696
  }
@@ -644,8 +701,8 @@ Which would you like to do?`,
644
701
  requestBody.data.relationships.field_affinity_group_node = {
645
702
  data: {
646
703
  type: "node--affinity_group",
647
- id: groupUuid
648
- }
704
+ id: groupUuid,
705
+ },
649
706
  };
650
707
  }
651
708
  else {
@@ -656,7 +713,7 @@ Which would you like to do?`,
656
713
  if (args.external_link) {
657
714
  requestBody.data.attributes.field_news_external_link = {
658
715
  uri: args.external_link.uri,
659
- title: args.external_link.title || ""
716
+ title: args.external_link.title || "",
660
717
  };
661
718
  }
662
719
  // Add where to share (defaults handled by Drupal if not provided)
@@ -664,17 +721,23 @@ Which would you like to do?`,
664
721
  requestBody.data.attributes.field_choose_where_to_share_this = this.normalizeWhereToShare(args.where_to_share);
665
722
  }
666
723
  const result = await auth.post("/jsonapi/node/access_news", requestBody);
724
+ const response = {
725
+ success: true,
726
+ message: "Announcement created (draft status)",
727
+ uuid: result.data?.id,
728
+ title: result.data?.attributes?.title,
729
+ edit_url: `${process.env.DRUPAL_API_URL}/node/${result.data?.attributes?.drupal_internal__nid}/edit`,
730
+ };
731
+ if (unmatchedTags.length > 0) {
732
+ response.warning = `These tags were not found and were skipped: ${unmatchedTags.join(", ")}. Use suggest_tags to get valid tag names.`;
733
+ }
667
734
  return {
668
- content: [{
735
+ content: [
736
+ {
669
737
  type: "text",
670
- text: JSON.stringify({
671
- success: true,
672
- message: "Announcement created (draft status)",
673
- uuid: result.data?.id,
674
- title: result.data?.attributes?.title,
675
- edit_url: `${process.env.DRUPAL_API_URL}/node/${result.data?.attributes?.drupal_internal__nid}/edit`
676
- })
677
- }]
738
+ text: JSON.stringify(response),
739
+ },
740
+ ],
678
741
  };
679
742
  }
680
743
  /**
@@ -686,8 +749,8 @@ Which would you like to do?`,
686
749
  data: {
687
750
  type: "node--access_news",
688
751
  id: args.uuid,
689
- attributes: {}
690
- }
752
+ attributes: {},
753
+ },
691
754
  };
692
755
  // Add fields to update
693
756
  if (args.title) {
@@ -702,7 +765,7 @@ Which would you like to do?`,
702
765
  requestBody.data.attributes.body = {
703
766
  value: args.body || existingBody,
704
767
  format: "basic_html",
705
- summary: args.summary !== undefined ? args.summary : existingSummary
768
+ summary: args.summary !== undefined ? args.summary : existingSummary,
706
769
  };
707
770
  }
708
771
  // If neither body nor summary provided, don't include body in request at all
@@ -710,17 +773,19 @@ Which would you like to do?`,
710
773
  requestBody.data.attributes.field_published_date = args.published_date;
711
774
  }
712
775
  // Update tags if provided
776
+ const unmatchedTags = [];
713
777
  if (args.tags && args.tags.length > 0) {
714
- const tagUuids = await this.getTagUuidsByName(args.tags);
778
+ const { uuids: tagUuids, unmatched } = await this.resolveTagNames(args.tags);
779
+ unmatchedTags.push(...unmatched);
715
780
  if (tagUuids.length > 0) {
716
781
  if (!requestBody.data.relationships) {
717
782
  requestBody.data.relationships = {};
718
783
  }
719
784
  requestBody.data.relationships.field_tags = {
720
- data: tagUuids.map(uuid => ({
785
+ data: tagUuids.map((uuid) => ({
721
786
  type: "taxonomy_term--tags",
722
- id: uuid
723
- }))
787
+ id: uuid,
788
+ })),
724
789
  };
725
790
  }
726
791
  }
@@ -734,8 +799,8 @@ Which would you like to do?`,
734
799
  requestBody.data.relationships.field_affinity_group_node = {
735
800
  data: {
736
801
  type: "node--affinity_group",
737
- id: groupUuid
738
- }
802
+ id: groupUuid,
803
+ },
739
804
  };
740
805
  }
741
806
  else {
@@ -746,7 +811,7 @@ Which would you like to do?`,
746
811
  if (args.external_link) {
747
812
  requestBody.data.attributes.field_news_external_link = {
748
813
  uri: args.external_link.uri,
749
- title: args.external_link.title || ""
814
+ title: args.external_link.title || "",
750
815
  };
751
816
  }
752
817
  // Update where to share if provided
@@ -756,17 +821,23 @@ Which would you like to do?`,
756
821
  const result = await auth.patch(`/jsonapi/node/access_news/${args.uuid}`, requestBody);
757
822
  const nid = result.data?.attributes?.drupal_internal__nid;
758
823
  const baseUrl = process.env.DRUPAL_API_URL;
824
+ const response = {
825
+ success: true,
826
+ message: "Announcement updated",
827
+ uuid: result.data?.id,
828
+ title: result.data?.attributes?.title,
829
+ edit_url: nid ? `${baseUrl}/node/${nid}/edit` : null,
830
+ };
831
+ if (unmatchedTags.length > 0) {
832
+ response.warning = `These tags were not found and were skipped: ${unmatchedTags.join(", ")}. Use suggest_tags to get valid tag names.`;
833
+ }
759
834
  return {
760
- content: [{
835
+ content: [
836
+ {
761
837
  type: "text",
762
- text: JSON.stringify({
763
- success: true,
764
- message: "Announcement updated",
765
- uuid: result.data?.id,
766
- title: result.data?.attributes?.title,
767
- edit_url: nid ? `${baseUrl}/node/${nid}/edit` : null
768
- })
769
- }]
838
+ text: JSON.stringify(response),
839
+ },
840
+ ],
770
841
  };
771
842
  }
772
843
  /**
@@ -776,41 +847,46 @@ Which would you like to do?`,
776
847
  // Enforce confirmation parameter
777
848
  if (!args.confirmed) {
778
849
  return {
779
- content: [{
850
+ content: [
851
+ {
780
852
  type: "text",
781
853
  text: JSON.stringify({
782
854
  error: "Deletion requires explicit confirmation. You must show the announcement title and status to the user and get explicit confirmation before setting confirmed=true.",
783
- uuid: args.uuid
784
- })
785
- }]
855
+ uuid: args.uuid,
856
+ }),
857
+ },
858
+ ],
786
859
  };
787
860
  }
788
861
  const auth = this.getDrupalAuth();
789
862
  await auth.delete(`/jsonapi/node/access_news/${args.uuid}`);
790
863
  return {
791
- content: [{
864
+ content: [
865
+ {
792
866
  type: "text",
793
867
  text: JSON.stringify({
794
868
  success: true,
795
869
  message: "Announcement deleted",
796
- uuid: args.uuid
797
- })
798
- }]
870
+ uuid: args.uuid,
871
+ }),
872
+ },
873
+ ],
799
874
  };
800
875
  }
801
876
  /**
802
- * Get announcements created by the acting user
877
+ * Get announcements created by the acting user.
878
+ *
879
+ * Uses a Drupal Views page display exposed via jsonapi_views at
880
+ * /jsonapi/views/mcp_my_announcements/page_1.
881
+ * The JsonApiViewsUserParameterSubscriber resolves X-Acting-User header to uid
882
+ * and injects it as the contextual filter argument, so no user UUID lookup is needed.
803
883
  */
804
884
  async getMyAnnouncements(args) {
805
885
  const auth = this.getDrupalAuth();
806
- // Get the acting user's UUID
807
- const actingUserUid = this.getActingUserUid();
808
- const userUuid = await this.getUserUuidByUid(actingUserUid);
809
- if (!userUuid) {
810
- throw new Error(`Could not find user with UID ${actingUserUid}. Verify the ACTING_USER_UID is correct.`);
811
- }
886
+ // Ensure acting user is set (will throw if not available)
887
+ this.getActingUserAccessId();
812
888
  const limit = args.limit || 25;
813
- const result = await auth.get(`/jsonapi/node/access_news?filter[uid.id]=${userUuid}&page[limit]=${limit}&sort=-created`);
889
+ const result = await auth.get(`/jsonapi/views/mcp_my_announcements/page_1?page[limit]=${limit}`);
814
890
  const baseUrl = process.env.DRUPAL_API_URL;
815
891
  const announcements = (result.data || []).map((item) => {
816
892
  const nid = item.attributes?.drupal_internal__nid;
@@ -822,83 +898,129 @@ Which would you like to do?`,
822
898
  created: item.attributes?.created,
823
899
  published_date: item.attributes?.field_published_date,
824
900
  summary: item.attributes?.body?.summary ||
825
- (item.attributes?.body?.value ?
826
- item.attributes.body.value.replace(/<[^>]*>/g, '').substring(0, 200) + '...' : ''),
827
- edit_url: nid ? `${baseUrl}/node/${nid}/edit` : null
901
+ (item.attributes?.body?.value
902
+ ? item.attributes.body.value.replace(/<[^>]*>/g, "").substring(0, 200) + "..."
903
+ : ""),
904
+ edit_url: nid ? `${baseUrl}/node/${nid}/edit` : null,
828
905
  };
829
906
  });
830
907
  return {
831
- content: [{
908
+ content: [
909
+ {
832
910
  type: "text",
833
911
  text: JSON.stringify({
834
912
  total: announcements.length,
835
- items: announcements
836
- })
837
- }]
913
+ items: announcements,
914
+ }),
915
+ },
916
+ ],
838
917
  };
839
918
  }
840
919
  // ============================================================================
841
920
  // Helper Methods
842
921
  // ============================================================================
843
922
  /**
844
- * Get a user's UUID by their Drupal user ID (internal helper)
845
- */
846
- async getUserUuidByUid(uid) {
847
- const auth = this.getDrupalAuth();
848
- const result = await auth.get(`/jsonapi/user/user?filter[drupal_internal__uid]=${uid}`);
849
- if (!result.data || result.data.length === 0) {
850
- return null;
851
- }
852
- return result.data[0].id;
853
- }
854
- /**
855
- * Get announcement context - tags, affinity groups, and options for creating announcements
923
+ * Get announcement context - tags, affinity groups, and options for creating announcements.
924
+ *
925
+ * Uses a Drupal Views page display exposed via jsonapi_views at
926
+ * /jsonapi/views/mcp_my_affinity_groups/page_1 for affinity groups lookup.
927
+ * The JsonApiViewsUserParameterSubscriber resolves X-Acting-User header to uid,
928
+ * so no user UUID lookup is needed.
856
929
  */
857
930
  async getAnnouncementContext() {
858
931
  const auth = this.getDrupalAuth();
859
- // Get the acting user's UUID
860
- const actingUserUid = this.getActingUserUid();
861
- const userUuid = await this.getUserUuidByUid(actingUserUid);
862
- if (!userUuid) {
863
- throw new Error(`Could not find user with UID ${actingUserUid}`);
864
- }
865
- // Fetch tags and affinity groups in parallel
866
- const [tagsResult, groupsResult] = await Promise.all([
867
- auth.get("/jsonapi/taxonomy_term/tags?page[limit]=100"),
868
- auth.get(`/jsonapi/node/affinity_group?filter[field_coordinator.id]=${userUuid}&filter[status]=1&page[limit]=100`)
869
- ]);
870
- const tags = (tagsResult.data || []).map((item) => ({
871
- name: item.attributes?.name,
872
- uuid: item.id
873
- }));
932
+ // Ensure acting user is set (will throw if not available)
933
+ this.getActingUserAccessId();
934
+ // Fetch affinity groups the user coordinates.
935
+ // Tags are NOT fetched here — use suggest_tags tool after the user provides content.
936
+ const groupsResult = await auth.get("/jsonapi/views/mcp_my_affinity_groups/page_1");
874
937
  const affinityGroups = (groupsResult.data || []).map((item) => ({
875
938
  id: item.attributes?.field_group_id,
876
939
  uuid: item.id,
877
940
  name: item.attributes?.title,
878
- category: item.attributes?.field_affinity_group_category
941
+ category: item.attributes?.field_affinity_group_category,
879
942
  }));
880
943
  const isCoordinator = affinityGroups.length > 0;
881
944
  return {
882
- content: [{
945
+ content: [
946
+ {
883
947
  type: "text",
884
948
  text: JSON.stringify({
885
- tags: tags,
886
949
  affinity_groups: affinityGroups,
887
950
  is_coordinator: isCoordinator,
888
951
  affiliations: ["ACCESS Collaboration", "Community"],
889
952
  where_to_share_options: [
890
953
  { value: "Announcements page", description: "Public announcements listing" },
891
954
  { value: "Bi-Weekly Digest", description: "ACCESS Support bi-weekly email digest" },
892
- { value: "Affinity Group page", description: "Your affinity group's page (coordinators only)" },
893
- { value: "Email to Affinity Group", description: "Direct email to affinity group members (coordinators only)" }
955
+ {
956
+ value: "Affinity Group page",
957
+ description: "Your affinity group's page (coordinators only)",
958
+ },
959
+ {
960
+ value: "Email to Affinity Group",
961
+ description: "Direct email to affinity group members (coordinators only)",
962
+ },
894
963
  ],
895
964
  guidance: isCoordinator
896
965
  ? "User is an affinity group coordinator. Ask about affinity_group association and where_to_share preferences."
897
- : "User is not a coordinator. Standard announcement fields apply."
898
- })
899
- }]
966
+ : "User is not a coordinator. Standard announcement fields apply.",
967
+ }),
968
+ },
969
+ ],
900
970
  };
901
971
  }
972
+ /**
973
+ * Suggest tags for announcement content using Drupal's AI tag suggestion service.
974
+ */
975
+ static JSON_HEADERS = {
976
+ "Content-Type": "application/json",
977
+ Accept: "application/json",
978
+ };
979
+ async suggestTags(args) {
980
+ if (!args.text || args.text.length < 100) {
981
+ return this.errorResponse("Text must be at least 100 characters for tag suggestions.");
982
+ }
983
+ const auth = this.getDrupalAuth();
984
+ const limit = args.limit || 6;
985
+ const result = await auth.post("/api/suggest-tags", {
986
+ text: args.text,
987
+ limit,
988
+ }, AnnouncementsServer.JSON_HEADERS);
989
+ if (result.tags) {
990
+ const tags = result.tags.slice(0, limit);
991
+ return {
992
+ content: [{
993
+ type: "text",
994
+ text: JSON.stringify({
995
+ suggested_tags: tags.map((t) => t.name),
996
+ tag_details: tags,
997
+ }),
998
+ }],
999
+ };
1000
+ }
1001
+ return this.errorResponse(result.error || "Tag suggestion failed");
1002
+ }
1003
+ /**
1004
+ * Generate a summary for announcement content using Drupal's AI summary service.
1005
+ */
1006
+ async suggestSummary(args) {
1007
+ if (!args.text || args.text.length < 100) {
1008
+ return this.errorResponse("Text must be at least 100 characters for summary generation.");
1009
+ }
1010
+ const auth = this.getDrupalAuth();
1011
+ const result = await auth.post("/api/suggest-summary", {
1012
+ text: args.text,
1013
+ }, AnnouncementsServer.JSON_HEADERS);
1014
+ if (result.summary) {
1015
+ return {
1016
+ content: [{
1017
+ type: "text",
1018
+ text: JSON.stringify({ summary: result.summary }),
1019
+ }],
1020
+ };
1021
+ }
1022
+ return this.errorResponse(result.error || "Summary generation failed");
1023
+ }
902
1024
  /**
903
1025
  * Look up affinity group UUID by ID or name
904
1026
  */
@@ -933,13 +1055,13 @@ Which would you like to do?`,
933
1055
  "email to affinity group": "email_to_your_affinity_group",
934
1056
  "email to your affinity group": "email_to_your_affinity_group",
935
1057
  // Also accept the raw values
936
- "on_the_announcements_page": "on_the_announcements_page",
937
- "in_the_access_support_bi_weekly_digest": "in_the_access_support_bi_weekly_digest",
938
- "on_your_affinity_group_page": "on_your_affinity_group_page",
939
- "email_to_your_affinity_group": "email_to_your_affinity_group",
1058
+ on_the_announcements_page: "on_the_announcements_page",
1059
+ in_the_access_support_bi_weekly_digest: "in_the_access_support_bi_weekly_digest",
1060
+ on_your_affinity_group_page: "on_your_affinity_group_page",
1061
+ email_to_your_affinity_group: "email_to_your_affinity_group",
940
1062
  };
941
1063
  normalizeWhereToShare(values) {
942
- return values.map(v => {
1064
+ return values.map((v) => {
943
1065
  const normalized = AnnouncementsServer.WHERE_TO_SHARE_MAP[v.toLowerCase()];
944
1066
  if (!normalized) {
945
1067
  throw new Error(`Invalid where_to_share value: "${v}". ` +
@@ -959,31 +1081,63 @@ Which would you like to do?`,
959
1081
  */
960
1082
  async populateTagCache() {
961
1083
  const auth = this.getDrupalAuth();
962
- const result = await auth.get("/jsonapi/taxonomy_term/tags?page[limit]=500");
963
- this.tagCache.clear();
964
- for (const item of result.data || []) {
965
- const name = item.attributes?.name?.toLowerCase();
966
- if (name && item.id) {
967
- this.tagCache.set(name, item.id);
1084
+ try {
1085
+ this.tagCache.clear();
1086
+ let url = "/jsonapi/taxonomy_term/tags?page[limit]=50";
1087
+ let pageCount = 0;
1088
+ const MAX_PAGES = 100;
1089
+ while (url && pageCount < MAX_PAGES) {
1090
+ pageCount++;
1091
+ const result = await auth.get(url);
1092
+ for (const item of result.data || []) {
1093
+ const name = item.attributes?.name?.toLowerCase();
1094
+ if (name && item.id) {
1095
+ this.tagCache.set(name, item.id);
1096
+ }
1097
+ }
1098
+ // Follow pagination links
1099
+ const nextHref = result.links?.next?.href;
1100
+ if (nextHref) {
1101
+ try {
1102
+ const parsed = new URL(nextHref);
1103
+ url = parsed.pathname + parsed.search;
1104
+ }
1105
+ catch {
1106
+ url = nextHref; // Already a relative path
1107
+ }
1108
+ }
1109
+ else {
1110
+ url = null;
1111
+ }
968
1112
  }
1113
+ this.logger.info("Tag cache populated", { count: this.tagCache.size });
1114
+ this.tagCacheExpiry = new Date(Date.now() + AnnouncementsServer.TAG_CACHE_TTL_MS);
1115
+ }
1116
+ catch (error) {
1117
+ const msg = error instanceof Error ? error.message : String(error);
1118
+ this.logger.error("Failed to populate tag cache", { error: msg });
1119
+ this.tagCache.clear(); // Don't leave partial data
969
1120
  }
970
- this.tagCacheExpiry = new Date(Date.now() + AnnouncementsServer.TAG_CACHE_TTL_MS);
971
1121
  }
972
1122
  /**
973
1123
  * Get tag UUIDs by their names (with caching)
974
1124
  */
975
- async getTagUuidsByName(tagNames) {
1125
+ async resolveTagNames(tagNames) {
976
1126
  // Ensure cache is populated
977
1127
  if (!this.isTagCacheValid()) {
978
1128
  await this.populateTagCache();
979
1129
  }
980
1130
  const uuids = [];
1131
+ const unmatched = [];
981
1132
  for (const name of tagNames) {
982
1133
  const uuid = this.tagCache.get(name.toLowerCase());
983
1134
  if (uuid) {
984
1135
  uuids.push(uuid);
985
1136
  }
1137
+ else {
1138
+ unmatched.push(name);
1139
+ }
986
1140
  }
987
- return uuids;
1141
+ return { uuids, unmatched };
988
1142
  }
989
1143
  }