@access-mcp/announcements 0.3.1 → 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.d.ts CHANGED
@@ -57,19 +57,32 @@ export declare class AnnouncementsServer extends BaseAccessServer {
57
57
  */
58
58
  private deleteAnnouncement;
59
59
  /**
60
- * Get announcements created by the acting user
60
+ * Get announcements created by the acting user.
61
+ *
62
+ * Uses a Drupal Views page display exposed via jsonapi_views at
63
+ * /jsonapi/views/mcp_my_announcements/page_1.
64
+ * The JsonApiViewsUserParameterSubscriber resolves X-Acting-User header to uid
65
+ * and injects it as the contextual filter argument, so no user UUID lookup is needed.
61
66
  */
62
67
  private getMyAnnouncements;
63
68
  /**
64
- * Get a user's UUID by their ACCESS ID (e.g., "user@access-ci.org").
69
+ * Get announcement context - tags, affinity groups, and options for creating announcements.
65
70
  *
66
- * The Drupal username should match the full ACCESS ID.
71
+ * Uses a Drupal Views page display exposed via jsonapi_views at
72
+ * /jsonapi/views/mcp_my_affinity_groups/page_1 for affinity groups lookup.
73
+ * The JsonApiViewsUserParameterSubscriber resolves X-Acting-User header to uid,
74
+ * so no user UUID lookup is needed.
67
75
  */
68
- private getUserUuidByAccessId;
76
+ private getAnnouncementContext;
69
77
  /**
70
- * Get announcement context - tags, affinity groups, and options for creating announcements
78
+ * Suggest tags for announcement content using Drupal's AI tag suggestion service.
71
79
  */
72
- private getAnnouncementContext;
80
+ private static readonly JSON_HEADERS;
81
+ private suggestTags;
82
+ /**
83
+ * Generate a summary for announcement content using Drupal's AI summary service.
84
+ */
85
+ private suggestSummary;
73
86
  /**
74
87
  * Look up affinity group UUID by ID or name
75
88
  */
@@ -90,5 +103,5 @@ export declare class AnnouncementsServer extends BaseAccessServer {
90
103
  /**
91
104
  * Get tag UUIDs by their names (with caching)
92
105
  */
93
- private getTagUuidsByName;
106
+ private resolveTagNames;
94
107
  }
package/dist/server.js CHANGED
@@ -77,12 +77,12 @@ export class AnnouncementsServer extends BaseAccessServer {
77
77
  description: `Create a new ACCESS announcement (saved as draft for staff review).
78
78
 
79
79
  BEFORE CALLING:
80
- 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.
81
81
  2. Gather information - either conversationally OR by parsing pasted content:
82
82
  - title (required): Clear, specific headline
83
83
  - body (required): Full content with details, dates, links. HTML supported.
84
84
  - summary (required): 1-2 sentence teaser for listings
85
- - 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
86
86
  - affiliation (optional): "ACCESS Collaboration" or "Community" (default)
87
87
  - external_link (if relevant): URL and link text for external references
88
88
  3. IF user is a coordinator (check get_announcement_context response):
@@ -93,19 +93,22 @@ WORKFLOW:
93
93
  If user pastes content (event description, draft text, etc.):
94
94
  1. Call get_announcement_context
95
95
  2. Parse the pasted content to extract title, body, dates, links, etc.
96
- 3. Match content to available tags from context
97
- 4. Ask only about missing/unclear fields
98
- 5. If coordinator: ask about affinity group and where to share
99
- 6. Show preview and get confirmation
100
- 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
101
102
 
102
103
  If user describes what they want conversationally:
103
104
  1. Call get_announcement_context
104
105
  2. Guide them through providing title, body, summary
105
- 3. Suggest relevant tags from context
106
+ 3. Call suggest_tags with the body text — include returned tag names in the tags parameter
106
107
  4. If coordinator: ask about affinity group and where to share
107
- 5. Show preview and get confirmation
108
- 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.
109
112
 
110
113
  PREVIEW FORMAT (show before creating):
111
114
  ---
@@ -147,7 +150,7 @@ ALWAYS display the edit_url to the user so they can review their draft in Drupal
147
150
  tags: {
148
151
  type: "array",
149
152
  items: { type: "string" },
150
- 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.',
151
154
  },
152
155
  affiliation: {
153
156
  type: "string",
@@ -297,18 +300,49 @@ Use this to:
297
300
  description: `Get user context and options BEFORE creating an announcement. Call this first.
298
301
 
299
302
  Returns:
300
- - tags: Available tags for announcements [{name, uuid}]
301
303
  - affinity_groups: Groups the user coordinates (empty if not a coordinator)
302
304
  - is_coordinator: Boolean - if true, ask about affinity_group and where_to_share
303
305
  - affiliations: Available affiliation options
304
306
  - where_to_share_options: Available sharing options (only relevant for coordinators)
305
307
 
306
- 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.`,
307
309
  inputSchema: {
308
310
  type: "object",
309
311
  properties: {},
310
312
  },
311
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
+ },
312
346
  ];
313
347
  }
314
348
  getResources() {
@@ -361,9 +395,9 @@ Use this to tailor the announcement creation conversation based on the user's ro
361
395
  role: "assistant",
362
396
  content: {
363
397
  type: "text",
364
- 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.
365
399
 
366
- 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.
367
401
 
368
402
  **Required Information:**
369
403
 
@@ -461,6 +495,10 @@ Which would you like to do?`,
461
495
  // Helper operations
462
496
  case "get_announcement_context":
463
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);
464
502
  default:
465
503
  return this.errorResponse(`Unknown tool: ${name}`);
466
504
  }
@@ -599,9 +637,9 @@ Which would you like to do?`,
599
637
  if (envUser) {
600
638
  return envUser;
601
639
  }
602
- throw new Error("Cannot create announcement: No acting user specified.\n\n" +
603
- "Either set the X-Acting-User header or the ACTING_USER environment variable " +
604
- "to the ACCESS ID (e.g., username@access-ci.org) of the person creating the announcement.");
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.");
605
643
  }
606
644
  /**
607
645
  * Create a new announcement via Drupal JSON:API
@@ -643,8 +681,10 @@ Which would you like to do?`,
643
681
  requestBody.data.attributes.field_affiliation = args.affiliation;
644
682
  }
645
683
  // Look up tag UUIDs if tags provided
684
+ const unmatchedTags = [];
646
685
  if (args.tags && args.tags.length > 0) {
647
- const tagUuids = await this.getTagUuidsByName(args.tags);
686
+ const { uuids: tagUuids, unmatched } = await this.resolveTagNames(args.tags);
687
+ unmatchedTags.push(...unmatched);
648
688
  if (tagUuids.length > 0) {
649
689
  requestBody.data.relationships.field_tags = {
650
690
  data: tagUuids.map((uuid) => ({
@@ -681,17 +721,21 @@ Which would you like to do?`,
681
721
  requestBody.data.attributes.field_choose_where_to_share_this = this.normalizeWhereToShare(args.where_to_share);
682
722
  }
683
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
+ }
684
734
  return {
685
735
  content: [
686
736
  {
687
737
  type: "text",
688
- text: JSON.stringify({
689
- success: true,
690
- message: "Announcement created (draft status)",
691
- uuid: result.data?.id,
692
- title: result.data?.attributes?.title,
693
- edit_url: `${process.env.DRUPAL_API_URL}/node/${result.data?.attributes?.drupal_internal__nid}/edit`,
694
- }),
738
+ text: JSON.stringify(response),
695
739
  },
696
740
  ],
697
741
  };
@@ -729,8 +773,10 @@ Which would you like to do?`,
729
773
  requestBody.data.attributes.field_published_date = args.published_date;
730
774
  }
731
775
  // Update tags if provided
776
+ const unmatchedTags = [];
732
777
  if (args.tags && args.tags.length > 0) {
733
- const tagUuids = await this.getTagUuidsByName(args.tags);
778
+ const { uuids: tagUuids, unmatched } = await this.resolveTagNames(args.tags);
779
+ unmatchedTags.push(...unmatched);
734
780
  if (tagUuids.length > 0) {
735
781
  if (!requestBody.data.relationships) {
736
782
  requestBody.data.relationships = {};
@@ -775,17 +821,21 @@ Which would you like to do?`,
775
821
  const result = await auth.patch(`/jsonapi/node/access_news/${args.uuid}`, requestBody);
776
822
  const nid = result.data?.attributes?.drupal_internal__nid;
777
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
+ }
778
834
  return {
779
835
  content: [
780
836
  {
781
837
  type: "text",
782
- text: JSON.stringify({
783
- success: true,
784
- message: "Announcement updated",
785
- uuid: result.data?.id,
786
- title: result.data?.attributes?.title,
787
- edit_url: nid ? `${baseUrl}/node/${nid}/edit` : null,
788
- }),
838
+ text: JSON.stringify(response),
789
839
  },
790
840
  ],
791
841
  };
@@ -824,19 +874,19 @@ Which would you like to do?`,
824
874
  };
825
875
  }
826
876
  /**
827
- * 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.
828
883
  */
829
884
  async getMyAnnouncements(args) {
830
885
  const auth = this.getDrupalAuth();
831
- // Get the acting user's UUID by looking up their ACCESS ID
832
- const actingUserAccessId = this.getActingUserAccessId();
833
- const userUuid = await this.getUserUuidByAccessId(actingUserAccessId);
834
- if (!userUuid) {
835
- throw new Error(`Could not find Drupal user for ACCESS ID "${actingUserAccessId}". ` +
836
- `Verify the user exists in Drupal.`);
837
- }
886
+ // Ensure acting user is set (will throw if not available)
887
+ this.getActingUserAccessId();
838
888
  const limit = args.limit || 25;
839
- 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}`);
840
890
  const baseUrl = process.env.DRUPAL_API_URL;
841
891
  const announcements = (result.data || []).map((item) => {
842
892
  const nid = item.attributes?.drupal_internal__nid;
@@ -870,39 +920,20 @@ Which would you like to do?`,
870
920
  // Helper Methods
871
921
  // ============================================================================
872
922
  /**
873
- * Get a user's UUID by their ACCESS ID (e.g., "user@access-ci.org").
923
+ * Get announcement context - tags, affinity groups, and options for creating announcements.
874
924
  *
875
- * The Drupal username should match the full ACCESS ID.
876
- */
877
- async getUserUuidByAccessId(accessId) {
878
- const auth = this.getDrupalAuth();
879
- const result = await auth.get(`/jsonapi/user/user?filter[name]=${encodeURIComponent(accessId)}`);
880
- if (!result.data || result.data.length === 0) {
881
- return null;
882
- }
883
- return result.data[0].id;
884
- }
885
- /**
886
- * Get announcement context - tags, affinity groups, and options for creating announcements
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.
887
929
  */
888
930
  async getAnnouncementContext() {
889
931
  const auth = this.getDrupalAuth();
890
- // Get the acting user's UUID by looking up their ACCESS ID
891
- const actingUserAccessId = this.getActingUserAccessId();
892
- const userUuid = await this.getUserUuidByAccessId(actingUserAccessId);
893
- if (!userUuid) {
894
- throw new Error(`Could not find Drupal user for ACCESS ID "${actingUserAccessId}". ` +
895
- `Verify the user exists in Drupal.`);
896
- }
897
- // Fetch tags and affinity groups in parallel
898
- const [tagsResult, groupsResult] = await Promise.all([
899
- auth.get("/jsonapi/taxonomy_term/tags?page[limit]=100"),
900
- auth.get(`/jsonapi/node/affinity_group?filter[field_coordinator.id]=${userUuid}&filter[status]=1&page[limit]=100`),
901
- ]);
902
- const tags = (tagsResult.data || []).map((item) => ({
903
- name: item.attributes?.name,
904
- uuid: item.id,
905
- }));
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");
906
937
  const affinityGroups = (groupsResult.data || []).map((item) => ({
907
938
  id: item.attributes?.field_group_id,
908
939
  uuid: item.id,
@@ -915,7 +946,6 @@ Which would you like to do?`,
915
946
  {
916
947
  type: "text",
917
948
  text: JSON.stringify({
918
- tags: tags,
919
949
  affinity_groups: affinityGroups,
920
950
  is_coordinator: isCoordinator,
921
951
  affiliations: ["ACCESS Collaboration", "Community"],
@@ -939,6 +969,58 @@ Which would you like to do?`,
939
969
  ],
940
970
  };
941
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
+ }
942
1024
  /**
943
1025
  * Look up affinity group UUID by ID or name
944
1026
  */
@@ -999,31 +1081,63 @@ Which would you like to do?`,
999
1081
  */
1000
1082
  async populateTagCache() {
1001
1083
  const auth = this.getDrupalAuth();
1002
- const result = await auth.get("/jsonapi/taxonomy_term/tags?page[limit]=500");
1003
- this.tagCache.clear();
1004
- for (const item of result.data || []) {
1005
- const name = item.attributes?.name?.toLowerCase();
1006
- if (name && item.id) {
1007
- 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
+ }
1008
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
1009
1120
  }
1010
- this.tagCacheExpiry = new Date(Date.now() + AnnouncementsServer.TAG_CACHE_TTL_MS);
1011
1121
  }
1012
1122
  /**
1013
1123
  * Get tag UUIDs by their names (with caching)
1014
1124
  */
1015
- async getTagUuidsByName(tagNames) {
1125
+ async resolveTagNames(tagNames) {
1016
1126
  // Ensure cache is populated
1017
1127
  if (!this.isTagCacheValid()) {
1018
1128
  await this.populateTagCache();
1019
1129
  }
1020
1130
  const uuids = [];
1131
+ const unmatched = [];
1021
1132
  for (const name of tagNames) {
1022
1133
  const uuid = this.tagCache.get(name.toLowerCase());
1023
1134
  if (uuid) {
1024
1135
  uuids.push(uuid);
1025
1136
  }
1137
+ else {
1138
+ unmatched.push(name);
1139
+ }
1026
1140
  }
1027
- return uuids;
1141
+ return { uuids, unmatched };
1028
1142
  }
1029
1143
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@access-mcp/announcements",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "MCP server for ACCESS Support Announcements API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -26,8 +26,8 @@
26
26
  "author": "ACCESS-CI",
27
27
  "license": "MIT",
28
28
  "dependencies": {
29
- "@access-mcp/shared": "*",
30
- "@modelcontextprotocol/sdk": "^0.5.0",
29
+ "@access-mcp/shared": "^0.9.0",
30
+ "@modelcontextprotocol/sdk": "^1.28.0",
31
31
  "axios": "^1.7.0"
32
32
  },
33
33
  "devDependencies": {
@@ -61,6 +61,7 @@ describe("AnnouncementsServer", () => {
61
61
  author: "ACCESS Support",
62
62
  tags: ["maintenance", "scheduled"],
63
63
  affinity_group: ["123", "456"],
64
+ url: "https://support.access-ci.org/announcements/scheduled-maintenance",
64
65
  },
65
66
  {
66
67
  title: "New GPU Nodes Available",
@@ -69,6 +70,7 @@ describe("AnnouncementsServer", () => {
69
70
  author: "Resource Team",
70
71
  tags: ["gpu", "hardware"],
71
72
  affinity_group: ["789"],
73
+ url: "https://support.access-ci.org/announcements/new-gpu-nodes-available",
72
74
  },
73
75
  ],
74
76
  };
@@ -195,6 +197,7 @@ describe("AnnouncementsServer", () => {
195
197
  author: "Support",
196
198
  tags: ["gpu", "maintenance"],
197
199
  affinity_group: [],
200
+ url: "https://support.access-ci.org/announcements/gpu-maintenance",
198
201
  },
199
202
  ],
200
203
  };
@@ -232,6 +235,7 @@ describe("AnnouncementsServer", () => {
232
235
  published_date: "2024-03-14",
233
236
  tags: ["ai"],
234
237
  affinity_group: [],
238
+ url: "https://support.access-ci.org/announcements/update-1",
235
239
  },
236
240
  ],
237
241
  };
@@ -268,6 +272,7 @@ describe("AnnouncementsServer", () => {
268
272
  author: "Admin",
269
273
  tags: ["urgent"],
270
274
  affinity_group: [],
275
+ url: "https://support.access-ci.org/announcements/todays-update",
271
276
  },
272
277
  ],
273
278
  };
@@ -367,6 +372,7 @@ describe("AnnouncementsServer", () => {
367
372
  published_date: "2024-03-15",
368
373
  tags: ["tag1", "tag2", "tag3"],
369
374
  affinity_group: [],
375
+ url: "https://support.access-ci.org/announcements/test",
370
376
  },
371
377
  ],
372
378
  };
@@ -394,6 +400,7 @@ describe("AnnouncementsServer", () => {
394
400
  published_date: "2024-03-15",
395
401
  tags: "gpu, machine-learning, hpc",
396
402
  affinity_group: [],
403
+ url: "https://support.access-ci.org/announcements/test",
397
404
  },
398
405
  ],
399
406
  };
@@ -421,6 +428,7 @@ describe("AnnouncementsServer", () => {
421
428
  published_date: "2024-03-15",
422
429
  tags: "",
423
430
  affinity_group: [],
431
+ url: "https://support.access-ci.org/announcements/test",
424
432
  },
425
433
  ],
426
434
  };
@@ -448,20 +456,23 @@ describe("AnnouncementsServer", () => {
448
456
  published_date: "2024-03-15",
449
457
  tags: ["gpu", "maintenance"],
450
458
  affinity_group: [],
459
+ url: "https://support.access-ci.org/announcements/1",
451
460
  },
452
461
  {
453
462
  title: "2",
454
463
  published_date: "2024-03-14",
455
464
  tags: ["gpu", "network"],
456
465
  affinity_group: [],
466
+ url: "https://support.access-ci.org/announcements/2",
457
467
  },
458
468
  {
459
469
  title: "3",
460
470
  published_date: "2024-03-13",
461
471
  tags: ["gpu", "storage"],
462
472
  affinity_group: [],
473
+ url: "https://support.access-ci.org/announcements/3",
463
474
  },
464
- { title: "4", published_date: "2024-03-12", tags: ["maintenance"], affinity_group: [] },
475
+ { title: "4", published_date: "2024-03-12", tags: ["maintenance"], affinity_group: [], url: "https://support.access-ci.org/announcements/4" },
465
476
  ],
466
477
  };
467
478
 
@@ -489,6 +500,7 @@ describe("AnnouncementsServer", () => {
489
500
  published_date: "2024-03-15",
490
501
  tags: [],
491
502
  affinity_group: [],
503
+ url: "https://support.access-ci.org/announcements/test",
492
504
  },
493
505
  ],
494
506
  };
@@ -604,6 +616,7 @@ describe("AnnouncementsServer", () => {
604
616
  { id: "tag-uuid-2", attributes: { name: "maintenance" } },
605
617
  { id: "tag-uuid-3", attributes: { name: "hpc" } },
606
618
  ],
619
+ links: {},
607
620
  });
608
621
 
609
622
  mockDrupalAuth.post.mockResolvedValue({
@@ -627,7 +640,7 @@ describe("AnnouncementsServer", () => {
627
640
  });
628
641
 
629
642
  expect(mockDrupalAuth.get).toHaveBeenCalledWith(
630
- "/jsonapi/taxonomy_term/tags?page[limit]=500"
643
+ "/jsonapi/taxonomy_term/tags?page[limit]=50"
631
644
  );
632
645
 
633
646
  expect(mockDrupalAuth.post).toHaveBeenCalledWith(
@@ -1265,14 +1278,7 @@ describe("AnnouncementsServer", () => {
1265
1278
  });
1266
1279
 
1267
1280
  describe("get_announcement_context", () => {
1268
- it("should fetch tags and affinity groups without user UUID lookup", async () => {
1269
- // Two parallel calls only — no user UUID lookup
1270
- mockDrupalAuth.get.mockResolvedValueOnce({
1271
- data: [
1272
- { id: "tag-1", attributes: { name: "gpu" } },
1273
- { id: "tag-2", attributes: { name: "hpc" } },
1274
- ],
1275
- });
1281
+ it("should fetch affinity groups without tags", async () => {
1276
1282
  mockDrupalAuth.get.mockResolvedValueOnce({
1277
1283
  data: [
1278
1284
  {
@@ -1294,24 +1300,17 @@ describe("AnnouncementsServer", () => {
1294
1300
  },
1295
1301
  });
1296
1302
 
1297
- // Exactly 2 callstags + views affinity groups, no user lookup
1298
- expect(mockDrupalAuth.get).toHaveBeenCalledTimes(2);
1299
- expect(mockDrupalAuth.get).toHaveBeenCalledWith(
1300
- "/jsonapi/taxonomy_term/tags?page[limit]=100"
1301
- );
1303
+ // Only 1 call — affinity groups view, no tags fetch
1304
+ expect(mockDrupalAuth.get).toHaveBeenCalledTimes(1);
1302
1305
  expect(mockDrupalAuth.get).toHaveBeenCalledWith(
1303
1306
  "/jsonapi/views/mcp_my_affinity_groups/page_1"
1304
1307
  );
1305
1308
 
1306
1309
  const responseData = JSON.parse((result.content[0] as TextContent).text);
1307
- expect(responseData.tags).toHaveLength(2);
1308
- expect(responseData.tags[0].name).toBe("gpu");
1309
- expect(responseData.tags[1].name).toBe("hpc");
1310
+ // Should NOT return tags
1311
+ expect(responseData).not.toHaveProperty("tags");
1310
1312
  expect(responseData.affinity_groups).toHaveLength(1);
1311
1313
  expect(responseData.affinity_groups[0].name).toBe("Test Group");
1312
- expect(responseData.affinity_groups[0].id).toBe(123);
1313
- expect(responseData.affinity_groups[0].uuid).toBe("group-uuid-1");
1314
- expect(responseData.affinity_groups[0].category).toBe("Research");
1315
1314
  expect(responseData.is_coordinator).toBe(true);
1316
1315
  expect(responseData.affiliations).toContain("ACCESS Collaboration");
1317
1316
  expect(responseData.affiliations).toContain("Community");
@@ -1320,9 +1319,6 @@ describe("AnnouncementsServer", () => {
1320
1319
  });
1321
1320
 
1322
1321
  it("should indicate non-coordinator when views returns no affinity groups", async () => {
1323
- mockDrupalAuth.get.mockResolvedValueOnce({
1324
- data: [{ id: "tag-1", attributes: { name: "gpu" } }],
1325
- });
1326
1322
  mockDrupalAuth.get.mockResolvedValueOnce({
1327
1323
  data: [],
1328
1324
  });
@@ -1354,14 +1350,12 @@ describe("AnnouncementsServer", () => {
1354
1350
 
1355
1351
  const responseData = JSON.parse((result.content[0] as TextContent).text);
1356
1352
  expect(responseData.error).toContain("No acting user specified");
1357
- // Should not have made any API calls
1358
1353
  expect(mockDrupalAuth.get).not.toHaveBeenCalled();
1359
1354
  });
1360
1355
 
1361
1356
  it("should use acting user from request context", async () => {
1362
1357
  delete process.env.ACTING_USER;
1363
1358
 
1364
- mockDrupalAuth.get.mockResolvedValueOnce({ data: [] });
1365
1359
  mockDrupalAuth.get.mockResolvedValueOnce({ data: [] });
1366
1360
 
1367
1361
  const context: RequestContext = {
@@ -1378,11 +1372,116 @@ describe("AnnouncementsServer", () => {
1378
1372
  });
1379
1373
  });
1380
1374
 
1381
- // Should succeed — acting user from request context
1382
1375
  const responseData = JSON.parse((result.content[0] as TextContent).text);
1383
- expect(responseData.tags).toHaveLength(0);
1376
+ expect(responseData).not.toHaveProperty("tags");
1384
1377
  expect(responseData.is_coordinator).toBe(false);
1385
- expect(mockDrupalAuth.get).toHaveBeenCalledTimes(2);
1378
+ expect(mockDrupalAuth.get).toHaveBeenCalledTimes(1);
1379
+ });
1380
+ });
1381
+
1382
+ describe("suggest_tags", () => {
1383
+ it("should return error when text is too short", async () => {
1384
+ const result = await server["handleToolCall"]({
1385
+ method: "tools/call",
1386
+ params: {
1387
+ name: "suggest_tags",
1388
+ arguments: { text: "Short text" },
1389
+ },
1390
+ });
1391
+
1392
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1393
+ expect(responseData.error).toContain("at least 100 characters");
1394
+ });
1395
+
1396
+ it("should return error when text is empty", async () => {
1397
+ const result = await server["handleToolCall"]({
1398
+ method: "tools/call",
1399
+ params: {
1400
+ name: "suggest_tags",
1401
+ arguments: { text: "" },
1402
+ },
1403
+ });
1404
+
1405
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1406
+ expect(responseData.error).toContain("at least 100 characters");
1407
+ });
1408
+
1409
+ it("should return suggested tags on success", async () => {
1410
+ const longText = "A".repeat(150);
1411
+ mockDrupalAuth.post.mockResolvedValueOnce({
1412
+ tags: [
1413
+ { tid: 1, name: "ai", uuid: "uuid-1" },
1414
+ { tid: 2, name: "hpc", uuid: "uuid-2" },
1415
+ ],
1416
+ });
1417
+
1418
+ const result = await server["handleToolCall"]({
1419
+ method: "tools/call",
1420
+ params: {
1421
+ name: "suggest_tags",
1422
+ arguments: { text: longText },
1423
+ },
1424
+ });
1425
+
1426
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1427
+ expect(responseData.suggested_tags).toEqual(["ai", "hpc"]);
1428
+ expect(responseData.tag_details).toHaveLength(2);
1429
+ });
1430
+
1431
+ it("should truncate results to the specified limit", async () => {
1432
+ const longText = "A".repeat(150);
1433
+ mockDrupalAuth.post.mockResolvedValueOnce({
1434
+ tags: [
1435
+ { tid: 1, name: "ai", uuid: "uuid-1" },
1436
+ { tid: 2, name: "hpc", uuid: "uuid-2" },
1437
+ { tid: 3, name: "gpu", uuid: "uuid-3" },
1438
+ ],
1439
+ });
1440
+
1441
+ const result = await server["handleToolCall"]({
1442
+ method: "tools/call",
1443
+ params: {
1444
+ name: "suggest_tags",
1445
+ arguments: { text: longText, limit: 2 },
1446
+ },
1447
+ });
1448
+
1449
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1450
+ expect(responseData.suggested_tags).toHaveLength(2);
1451
+ expect(responseData.tag_details).toHaveLength(2);
1452
+ });
1453
+ });
1454
+
1455
+ describe("suggest_summary", () => {
1456
+ it("should return error when text is too short", async () => {
1457
+ const result = await server["handleToolCall"]({
1458
+ method: "tools/call",
1459
+ params: {
1460
+ name: "suggest_summary",
1461
+ arguments: { text: "Short text" },
1462
+ },
1463
+ });
1464
+
1465
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1466
+ expect(responseData.error).toContain("at least 100 characters");
1467
+ });
1468
+
1469
+ it("should return summary on success", async () => {
1470
+ const longText = "A".repeat(150);
1471
+ mockDrupalAuth.post.mockResolvedValueOnce({
1472
+ summary: "A test summary of the content.",
1473
+ });
1474
+
1475
+ const result = await server["handleToolCall"]({
1476
+ method: "tools/call",
1477
+ params: {
1478
+ name: "suggest_summary",
1479
+ arguments: { text: longText },
1480
+ },
1481
+ });
1482
+
1483
+ const responseData = JSON.parse((result.content[0] as TextContent).text);
1484
+ expect(responseData.summary).toBe("A test summary of the content.");
1386
1485
  });
1387
1486
  });
1388
1487
  });
package/src/server.ts CHANGED
@@ -210,7 +210,8 @@ export class AnnouncementsServer extends BaseAccessServer {
210
210
  },
211
211
  tags: {
212
212
  type: "string",
213
- 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.",
214
215
  },
215
216
  date: {
216
217
  type: "string",
@@ -232,12 +233,12 @@ export class AnnouncementsServer extends BaseAccessServer {
232
233
  description: `Create a new ACCESS announcement (saved as draft for staff review).
233
234
 
234
235
  BEFORE CALLING:
235
- 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.
236
237
  2. Gather information - either conversationally OR by parsing pasted content:
237
238
  - title (required): Clear, specific headline
238
239
  - body (required): Full content with details, dates, links. HTML supported.
239
240
  - summary (required): 1-2 sentence teaser for listings
240
- - 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
241
242
  - affiliation (optional): "ACCESS Collaboration" or "Community" (default)
242
243
  - external_link (if relevant): URL and link text for external references
243
244
  3. IF user is a coordinator (check get_announcement_context response):
@@ -248,19 +249,22 @@ WORKFLOW:
248
249
  If user pastes content (event description, draft text, etc.):
249
250
  1. Call get_announcement_context
250
251
  2. Parse the pasted content to extract title, body, dates, links, etc.
251
- 3. Match content to available tags from context
252
- 4. Ask only about missing/unclear fields
253
- 5. If coordinator: ask about affinity group and where to share
254
- 6. Show preview and get confirmation
255
- 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
256
258
 
257
259
  If user describes what they want conversationally:
258
260
  1. Call get_announcement_context
259
261
  2. Guide them through providing title, body, summary
260
- 3. Suggest relevant tags from context
262
+ 3. Call suggest_tags with the body text — include returned tag names in the tags parameter
261
263
  4. If coordinator: ask about affinity group and where to share
262
- 5. Show preview and get confirmation
263
- 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.
264
268
 
265
269
  PREVIEW FORMAT (show before creating):
266
270
  ---
@@ -304,7 +308,7 @@ ALWAYS display the edit_url to the user so they can review their draft in Drupal
304
308
  type: "array",
305
309
  items: { type: "string" },
306
310
  description:
307
- "Tag names (1-6 required for publication). Use list_available_tags to find valid tags.",
311
+ 'Tag names as strings, e.g. ["ai", "machine-learning", "gpu"]. Get these from the "name" field in suggest_tags response.',
308
312
  },
309
313
  affiliation: {
310
314
  type: "string",
@@ -458,18 +462,49 @@ Use this to:
458
462
  description: `Get user context and options BEFORE creating an announcement. Call this first.
459
463
 
460
464
  Returns:
461
- - tags: Available tags for announcements [{name, uuid}]
462
465
  - affinity_groups: Groups the user coordinates (empty if not a coordinator)
463
466
  - is_coordinator: Boolean - if true, ask about affinity_group and where_to_share
464
467
  - affiliations: Available affiliation options
465
468
  - where_to_share_options: Available sharing options (only relevant for coordinators)
466
469
 
467
- 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.`,
468
471
  inputSchema: {
469
472
  type: "object",
470
473
  properties: {},
471
474
  },
472
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
+ },
473
508
  ];
474
509
  }
475
510
 
@@ -530,9 +565,9 @@ Use this to tailor the announcement creation conversation based on the user's ro
530
565
  role: "assistant",
531
566
  content: {
532
567
  type: "text",
533
- 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.
534
569
 
535
- 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.
536
571
 
537
572
  **Required Information:**
538
573
 
@@ -636,6 +671,10 @@ Which would you like to do?`,
636
671
  // Helper operations
637
672
  case "get_announcement_context":
638
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 });
639
678
 
640
679
  default:
641
680
  return this.errorResponse(`Unknown tool: ${name}`);
@@ -793,9 +832,9 @@ Which would you like to do?`,
793
832
  }
794
833
 
795
834
  throw new Error(
796
- "Cannot create announcement: No acting user specified.\n\n" +
797
- "Either set the X-Acting-User header or the ACTING_USER environment variable " +
798
- "to the ACCESS ID (e.g., username@access-ci.org) of the person creating the announcement."
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."
799
838
  );
800
839
  }
801
840
 
@@ -843,8 +882,10 @@ Which would you like to do?`,
843
882
  }
844
883
 
845
884
  // Look up tag UUIDs if tags provided
885
+ const unmatchedTags: string[] = [];
846
886
  if (args.tags && args.tags.length > 0) {
847
- const tagUuids = await this.getTagUuidsByName(args.tags);
887
+ const { uuids: tagUuids, unmatched } = await this.resolveTagNames(args.tags);
888
+ unmatchedTags.push(...unmatched);
848
889
  if (tagUuids.length > 0) {
849
890
  requestBody.data.relationships!.field_tags = {
850
891
  data: tagUuids.map((uuid) => ({
@@ -889,17 +930,23 @@ Which would you like to do?`,
889
930
 
890
931
  const result = await auth.post("/jsonapi/node/access_news", requestBody);
891
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
+
892
945
  return {
893
946
  content: [
894
947
  {
895
948
  type: "text" as const,
896
- text: JSON.stringify({
897
- success: true,
898
- message: "Announcement created (draft status)",
899
- uuid: result.data?.id,
900
- title: result.data?.attributes?.title,
901
- edit_url: `${process.env.DRUPAL_API_URL}/node/${result.data?.attributes?.drupal_internal__nid}/edit`,
902
- }),
949
+ text: JSON.stringify(response),
903
950
  },
904
951
  ],
905
952
  };
@@ -944,8 +991,10 @@ Which would you like to do?`,
944
991
  }
945
992
 
946
993
  // Update tags if provided
994
+ const unmatchedTags: string[] = [];
947
995
  if (args.tags && args.tags.length > 0) {
948
- const tagUuids = await this.getTagUuidsByName(args.tags);
996
+ const { uuids: tagUuids, unmatched } = await this.resolveTagNames(args.tags);
997
+ unmatchedTags.push(...unmatched);
949
998
  if (tagUuids.length > 0) {
950
999
  if (!requestBody.data.relationships) {
951
1000
  requestBody.data.relationships = {};
@@ -998,17 +1047,23 @@ Which would you like to do?`,
998
1047
  const nid = result.data?.attributes?.drupal_internal__nid;
999
1048
  const baseUrl = process.env.DRUPAL_API_URL;
1000
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
+
1001
1062
  return {
1002
1063
  content: [
1003
1064
  {
1004
1065
  type: "text" as const,
1005
- text: JSON.stringify({
1006
- success: true,
1007
- message: "Announcement updated",
1008
- uuid: result.data?.id,
1009
- title: result.data?.attributes?.title,
1010
- edit_url: nid ? `${baseUrl}/node/${nid}/edit` : null,
1011
- }),
1066
+ text: JSON.stringify(response),
1012
1067
  },
1013
1068
  ],
1014
1069
  };
@@ -1121,18 +1176,9 @@ Which would you like to do?`,
1121
1176
  // Ensure acting user is set (will throw if not available)
1122
1177
  this.getActingUserAccessId();
1123
1178
 
1124
- // Fetch tags and affinity groups in parallel
1125
- // Tags use standard JSON:API; affinity groups use a views endpoint
1126
- // that resolves X-Acting-User to the coordinator filter automatically
1127
- const [tagsResult, groupsResult] = await Promise.all([
1128
- auth.get("/jsonapi/taxonomy_term/tags?page[limit]=100"),
1129
- auth.get("/jsonapi/views/mcp_my_affinity_groups/page_1"),
1130
- ]);
1131
-
1132
- const tags = (tagsResult.data || []).map((item: JsonApiResourceItem) => ({
1133
- name: item.attributes?.name,
1134
- uuid: item.id,
1135
- }));
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");
1136
1182
 
1137
1183
  const affinityGroups = (groupsResult.data || []).map((item: JsonApiResourceItem) => ({
1138
1184
  id: item.attributes?.field_group_id,
@@ -1148,7 +1194,6 @@ Which would you like to do?`,
1148
1194
  {
1149
1195
  type: "text" as const,
1150
1196
  text: JSON.stringify({
1151
- tags: tags,
1152
1197
  affinity_groups: affinityGroups,
1153
1198
  is_coordinator: isCoordinator,
1154
1199
  affiliations: ["ACCESS Collaboration", "Community"],
@@ -1173,6 +1218,67 @@ Which would you like to do?`,
1173
1218
  };
1174
1219
  }
1175
1220
 
1221
+ /**
1222
+ * Suggest tags for announcement content using Drupal's AI tag suggestion service.
1223
+ */
1224
+ private static readonly JSON_HEADERS = {
1225
+ "Content-Type": "application/json",
1226
+ Accept: "application/json",
1227
+ };
1228
+
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
+ }
1233
+
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
+ };
1252
+ }
1253
+
1254
+ return this.errorResponse(result.error || "Tag suggestion failed");
1255
+ }
1256
+
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
+ }
1264
+
1265
+ const auth = this.getDrupalAuth();
1266
+ const result = await auth.post("/api/suggest-summary", {
1267
+ text: args.text,
1268
+ }, AnnouncementsServer.JSON_HEADERS);
1269
+
1270
+ if (result.summary) {
1271
+ return {
1272
+ content: [{
1273
+ type: "text" as const,
1274
+ text: JSON.stringify({ summary: result.summary }),
1275
+ }],
1276
+ };
1277
+ }
1278
+
1279
+ return this.errorResponse(result.error || "Summary generation failed");
1280
+ }
1281
+
1176
1282
  /**
1177
1283
  * Look up affinity group UUID by ID or name
1178
1284
  */
@@ -1249,36 +1355,64 @@ Which would you like to do?`,
1249
1355
  */
1250
1356
  private async populateTagCache(): Promise<void> {
1251
1357
  const auth = this.getDrupalAuth();
1252
- const result = await auth.get("/jsonapi/taxonomy_term/tags?page[limit]=500");
1253
-
1254
- this.tagCache.clear();
1255
- for (const item of result.data || []) {
1256
- const name = item.attributes?.name?.toLowerCase();
1257
- if (name && item.id) {
1258
- 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
+ }
1259
1385
  }
1260
- }
1261
1386
 
1262
- 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
+ }
1263
1394
  }
1264
1395
 
1265
1396
  /**
1266
1397
  * Get tag UUIDs by their names (with caching)
1267
1398
  */
1268
- private async getTagUuidsByName(tagNames: string[]): Promise<string[]> {
1399
+ private async resolveTagNames(tagNames: string[]): Promise<{ uuids: string[]; unmatched: string[] }> {
1269
1400
  // Ensure cache is populated
1270
1401
  if (!this.isTagCacheValid()) {
1271
1402
  await this.populateTagCache();
1272
1403
  }
1273
1404
 
1274
1405
  const uuids: string[] = [];
1406
+ const unmatched: string[] = [];
1275
1407
  for (const name of tagNames) {
1276
1408
  const uuid = this.tagCache.get(name.toLowerCase());
1277
1409
  if (uuid) {
1278
1410
  uuids.push(uuid);
1411
+ } else {
1412
+ unmatched.push(name);
1279
1413
  }
1280
1414
  }
1281
1415
 
1282
- return uuids;
1416
+ return { uuids, unmatched };
1283
1417
  }
1284
1418
  }