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