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