@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.d.ts +36 -9
- package/dist/server.js +371 -217
- package/package.json +3 -3
- package/src/index.ts +1 -1
- package/src/server.integration.test.ts +296 -8
- package/src/server.test.ts +443 -119
- package/src/server.ts +529 -275
- package/vitest.integration.config.ts +1 -1
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
|
|
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 (
|
|
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.
|
|
85
|
-
4.
|
|
86
|
-
5.
|
|
87
|
-
6.
|
|
88
|
-
7.
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
|
398
|
+
text: `I'll help you create an ACCESS announcement. Let me check your options first.
|
|
353
399
|
|
|
354
|
-
|
|
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
|
-
:
|
|
544
|
-
? announcement.tags.split(
|
|
545
|
-
: []
|
|
546
|
-
summary: announcement.summary ||
|
|
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
|
|
567
|
-
*
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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("
|
|
578
|
-
"
|
|
579
|
-
"
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
672
|
-
|
|
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.
|
|
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
|
-
|
|
764
|
-
|
|
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
|
-
//
|
|
807
|
-
|
|
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/
|
|
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,
|
|
827
|
-
|
|
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
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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
|
-
//
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
-
{
|
|
893
|
-
|
|
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
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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
|
|
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
|
}
|