Skip to content

Commit fdbdd9e

Browse files
gwennegclaude
andauthored
RHCLOUD-46247 Add events and config MCP tools (#4491)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c244d10 commit fdbdd9e

3 files changed

Lines changed: 325 additions & 2 deletions

File tree

mcp/src/main/java/com/redhat/cloud/notifications/mcp/BackendRestClient.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,37 @@ public interface BackendRestClient {
5959
@Path("/api/integrations/v1.0/endpoints/{id}/history/{history_id}/details")
6060
@Produces(APPLICATION_JSON)
6161
String getEndpointHistoryDetails(@RestHeader("x-rh-identity") String xRhIdentity, @RestPath UUID id, @RestPath("history_id") UUID historyId);
62+
63+
@GET
64+
@Path("/api/notifications/v1.0/notifications/events")
65+
@Produces(APPLICATION_JSON)
66+
String getEvents(@RestHeader("x-rh-identity") String xRhIdentity,
67+
@RestQuery List<String> bundleIds,
68+
@RestQuery List<String> appIds,
69+
@RestQuery String eventTypeDisplayName,
70+
@RestQuery String startDate,
71+
@RestQuery String endDate,
72+
@RestQuery List<String> endpointTypes,
73+
@RestQuery List<String> invocationResults,
74+
@RestQuery List<String> status,
75+
@RestQuery Boolean includeDetails,
76+
@RestQuery Boolean includePayload,
77+
@RestQuery Boolean includeActions,
78+
@RestQuery Integer limit,
79+
@RestQuery Integer pageNumber);
80+
81+
@GET
82+
@Path("/api/notifications/v1.0/org-config/daily-digest/time-preference")
83+
@Produces(APPLICATION_JSON)
84+
String getDailyDigestTimePreference(@RestHeader("x-rh-identity") String xRhIdentity);
85+
86+
@GET
87+
@Path("/api/notifications/v1.0/user-config/notification-event-type-preference")
88+
@Produces(APPLICATION_JSON)
89+
String getUserNotificationPreferences(@RestHeader("x-rh-identity") String xRhIdentity);
90+
91+
@GET
92+
@Path("/api/notifications/v1.0/user-config/notification-event-type-preference/{bundleName}/{applicationName}")
93+
@Produces(APPLICATION_JSON)
94+
String getUserNotificationPreferencesByApplication(@RestHeader("x-rh-identity") String xRhIdentity, @RestPath String bundleName, @RestPath String applicationName);
6295
}

mcp/src/main/java/com/redhat/cloud/notifications/mcp/McpServerTools.java

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
import jakarta.ws.rs.WebApplicationException;
1515
import org.eclipse.microprofile.rest.client.inject.RestClient;
1616

17+
import java.time.LocalDate;
18+
import java.time.format.DateTimeParseException;
1719
import java.util.List;
1820
import java.util.UUID;
1921
import java.util.function.Supplier;
@@ -135,6 +137,54 @@ public String getIntegrationHistoryDetails(
135137
() -> backendClient.getEndpointHistoryDetails(principal.getRawHeader(), parseUuid("integrationId", integrationId), parseUuid("historyId", historyId)));
136138
}
137139

140+
@Tool(description = "Retrieves notification event log entries. Returns a paginated list with fields: id, bundle, application, event_type, created, severity. By default, actions and payload are omitted — set includeActions=true to see delivery status per integration, and includePayload=true to see event content. Use the getBundle or getApplication tools first to obtain bundle/application UUIDs for filtering.")
141+
public String getEvents(
142+
@ToolArg(description = "Filter by bundle UUIDs (use getBundle to find UUIDs)", required = false) List<String> bundleIds,
143+
@ToolArg(description = "Filter by application UUIDs (use getApplication to find UUIDs)", required = false) List<String> appIds,
144+
@ToolArg(description = "Filter by event type display name", required = false) String eventTypeDisplayName,
145+
@ToolArg(description = "Filter events from this date (yyyy-MM-dd)", required = false) String startDate,
146+
@ToolArg(description = "Filter events until this date (yyyy-MM-dd)", required = false) String endDate,
147+
@ToolArg(description = "Filter by endpoint types: webhook, email_subscription, camel, ansible, drawer, pagerduty. Camel subtypes use colon notation: camel:slack, camel:teams, camel:google_chat, camel:splunk, camel:servicenow", required = false) List<String> endpointTypes,
148+
@ToolArg(description = "Filter by invocation result as string values: 'true' for success, 'false' for failure", required = false) List<String> invocationResults,
149+
@ToolArg(description = "Filter by notification status: SUCCESS, SENT, FAILED, PROCESSING", required = false) List<String> status,
150+
@ToolArg(description = "Include detailed information about each notification action (default: false)", required = false) Boolean includeDetails,
151+
@ToolArg(description = "Include the event payload in the response (default: false)", required = false) Boolean includePayload,
152+
@ToolArg(description = "Include notification actions (delivery attempts per integration) in the response (default: false)", required = false) Boolean includeActions,
153+
@ToolArg(description = "Number of items per page", required = false, defaultValue = "20") Integer limit,
154+
@ToolArg(description = "Page number (starts at 0)", required = false) Integer pageNumber) {
155+
LocalDate parsedStart = parseDate("startDate", startDate);
156+
LocalDate parsedEnd = parseDate("endDate", endDate);
157+
if (parsedStart != null && parsedEnd != null && parsedStart.isAfter(parsedEnd)) {
158+
throw new ToolCallException("startDate must not be after endDate");
159+
}
160+
McpPrincipal principal = (McpPrincipal) securityIdentity.getPrincipal();
161+
return executeRestCall("getEvents", principal,
162+
() -> backendClient.getEvents(principal.getRawHeader(), bundleIds, appIds, eventTypeDisplayName, startDate, endDate, endpointTypes, invocationResults, status, includeDetails, includePayload, includeActions, limit, pageNumber));
163+
}
164+
165+
@Tool(description = "Retrieves the daily digest time setting for the organization. Returns a UTC time string (e.g. \"09:00\") indicating when daily digest emails are sent.")
166+
public String getDailyDigestTimePreference() {
167+
McpPrincipal principal = (McpPrincipal) securityIdentity.getPrincipal();
168+
return executeRestCall("getDailyDigestTimePreference", principal,
169+
() -> backendClient.getDailyDigestTimePreference(principal.getRawHeader()));
170+
}
171+
172+
@Tool(description = "Retrieves user notification preferences for all bundles and applications. Returns a nested structure: bundles → applications → event types, each showing which subscription types (instant, daily, drawer) the user is subscribed to. Use getUserNotificationPreferencesByApplication for a single application.")
173+
public String getUserNotificationPreferences() {
174+
McpPrincipal principal = (McpPrincipal) securityIdentity.getPrincipal();
175+
return executeRestCall("getUserNotificationPreferences", principal,
176+
() -> backendClient.getUserNotificationPreferences(principal.getRawHeader()));
177+
}
178+
179+
@Tool(description = "Retrieves user notification preferences for a specific bundle and application. Returns event types with their subscription types (instant, daily, drawer) and whether the user is subscribed. Lighter than getUserNotificationPreferences when you only need one application.")
180+
public String getUserNotificationPreferencesByApplication(
181+
@NotBlank @ToolArg(description = "The name of the bundle") String bundleName,
182+
@NotBlank @ToolArg(description = "The name of the application") String applicationName) {
183+
McpPrincipal principal = (McpPrincipal) securityIdentity.getPrincipal();
184+
return executeRestCall("getUserNotificationPreferencesByApplication", principal,
185+
() -> backendClient.getUserNotificationPreferencesByApplication(principal.getRawHeader(), bundleName, applicationName));
186+
}
187+
138188
private static UUID parseUuid(String paramName, String value) {
139189
try {
140190
return UUID.fromString(value);
@@ -143,6 +193,17 @@ private static UUID parseUuid(String paramName, String value) {
143193
}
144194
}
145195

196+
private static LocalDate parseDate(String paramName, String value) {
197+
if (value == null || value.isBlank()) {
198+
return null;
199+
}
200+
try {
201+
return LocalDate.parse(value);
202+
} catch (DateTimeParseException e) {
203+
throw new ToolCallException("Invalid date for " + paramName + ": " + value + ", expected yyyy-MM-dd");
204+
}
205+
}
206+
146207
/**
147208
* Executes a REST client call, translating REST exceptions into MCP ToolCallExceptions.
148209
* Unexpected exceptions are left unhandled so the framework's default handler logs and

mcp/src/test/java/com/redhat/cloud/notifications/mcp/McpAuthTest.java

Lines changed: 231 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,57 @@ void afterEach() {
247247
}
248248
""";
249249

250+
private static final String GET_EVENTS_BODY = """
251+
{
252+
"jsonrpc": "2.0",
253+
"method": "tools/call",
254+
"id": 13,
255+
"params": {
256+
"name": "getEvents",
257+
"arguments": {}
258+
}
259+
}
260+
""";
261+
262+
private static final String GET_DAILY_DIGEST_TIME_PREFERENCE_BODY = """
263+
{
264+
"jsonrpc": "2.0",
265+
"method": "tools/call",
266+
"id": 14,
267+
"params": {
268+
"name": "getDailyDigestTimePreference",
269+
"arguments": {}
270+
}
271+
}
272+
""";
273+
274+
private static final String GET_USER_NOTIFICATION_PREFERENCES_BODY = """
275+
{
276+
"jsonrpc": "2.0",
277+
"method": "tools/call",
278+
"id": 15,
279+
"params": {
280+
"name": "getUserNotificationPreferences",
281+
"arguments": {}
282+
}
283+
}
284+
""";
285+
286+
private static final String GET_USER_NOTIFICATION_PREFERENCES_BY_APP_BODY = """
287+
{
288+
"jsonrpc": "2.0",
289+
"method": "tools/call",
290+
"id": 16,
291+
"params": {
292+
"name": "getUserNotificationPreferencesByApplication",
293+
"arguments": {
294+
"bundleName": "rhel",
295+
"applicationName": "patch"
296+
}
297+
}
298+
}
299+
""";
300+
250301
// --- Helpers ---
251302

252303
private static String validIdentity() {
@@ -366,10 +417,12 @@ public void testMcpInitializeWithEmptyUsernameIsRejected() {
366417
public void testToolsListWithValidIdentity() {
367418
postMcp(validIdentity(), TOOLS_LIST_BODY)
368419
.statusCode(200)
369-
.body("result.tools.size()", greaterThanOrEqualTo(10))
420+
.body("result.tools.size()", greaterThanOrEqualTo(14))
370421
.body("result.tools.name", hasItems("serverInfo", "whoami", "getSeverities",
371422
"getBundle", "getApplication", "getEventType",
372-
"getIntegrations", "getIntegration", "getIntegrationHistory", "getIntegrationHistoryDetails"));
423+
"getIntegrations", "getIntegration", "getIntegrationHistory", "getIntegrationHistoryDetails",
424+
"getEvents", "getDailyDigestTimePreference",
425+
"getUserNotificationPreferences", "getUserNotificationPreferencesByApplication"));
373426
}
374427

375428
@Test
@@ -786,6 +839,182 @@ public void testGetIntegrationHistoryDetailsWhenBackendReturns404() {
786839
micrometerAssertionHelper.assertCounterIncrement(AUTH_SUCCESS_COUNTER, 1);
787840
}
788841

842+
// --- getEvents tool tests ---
843+
844+
@Test
845+
public void testGetEventsWithValidIdentity() {
846+
String eventsJson = "{\"data\":[{\"id\":\"11111111-1111-1111-1111-111111111111\",\"bundle\":\"rhel\",\"application\":\"patch\",\"event_type\":\"New advisory\",\"created\":\"2026-05-12T10:00:00\"}],\"meta\":{\"count\":1},\"links\":{}}";
847+
MockServerLifecycleManager.getClient().stubFor(
848+
get(urlPathEqualTo("/api/notifications/v1.0/notifications/events"))
849+
.withHeader("x-rh-identity", equalTo(validIdentity()))
850+
.willReturn(aResponse()
851+
.withHeader("Content-Type", "application/json")
852+
.withBody(eventsJson))
853+
);
854+
855+
postMcp(validIdentity(), GET_EVENTS_BODY)
856+
.statusCode(200)
857+
.body("result.content[0].text", containsString("rhel"))
858+
.body("result.content[0].text", containsString("patch"))
859+
.body("result.content[0].text", containsString("New advisory"));
860+
micrometerAssertionHelper.assertCounterIncrement(AUTH_SUCCESS_COUNTER, 1);
861+
862+
MockServerLifecycleManager.getClient().verify(
863+
getRequestedFor(urlPathEqualTo("/api/notifications/v1.0/notifications/events"))
864+
.withHeader("x-rh-identity", equalTo(validIdentity()))
865+
);
866+
}
867+
868+
@Test
869+
public void testGetEventsWithoutIdentityIsRejected() {
870+
postMcp(null, GET_EVENTS_BODY).statusCode(401);
871+
}
872+
873+
@Test
874+
public void testGetEventsWhenBackendReturns404() {
875+
MockServerLifecycleManager.getClient().stubFor(
876+
get(urlPathEqualTo("/api/notifications/v1.0/notifications/events"))
877+
.willReturn(aResponse().withStatus(404))
878+
);
879+
880+
postMcp(validIdentity(), GET_EVENTS_BODY)
881+
.statusCode(200)
882+
.body("result.isError", is(true))
883+
.body("result.content[0].text", containsString("Resource not found"));
884+
micrometerAssertionHelper.assertCounterIncrement(AUTH_SUCCESS_COUNTER, 1);
885+
}
886+
887+
// --- getDailyDigestTimePreference tool tests ---
888+
889+
@Test
890+
public void testGetDailyDigestTimePreferenceWithValidIdentity() {
891+
String timeJson = "\"09:00\"";
892+
MockServerLifecycleManager.getClient().stubFor(
893+
get(urlPathEqualTo("/api/notifications/v1.0/org-config/daily-digest/time-preference"))
894+
.withHeader("x-rh-identity", equalTo(validIdentity()))
895+
.willReturn(aResponse()
896+
.withHeader("Content-Type", "application/json")
897+
.withBody(timeJson))
898+
);
899+
900+
postMcp(validIdentity(), GET_DAILY_DIGEST_TIME_PREFERENCE_BODY)
901+
.statusCode(200)
902+
.body("result.content[0].text", containsString("09:00"));
903+
micrometerAssertionHelper.assertCounterIncrement(AUTH_SUCCESS_COUNTER, 1);
904+
905+
MockServerLifecycleManager.getClient().verify(
906+
getRequestedFor(urlPathEqualTo("/api/notifications/v1.0/org-config/daily-digest/time-preference"))
907+
.withHeader("x-rh-identity", equalTo(validIdentity()))
908+
);
909+
}
910+
911+
@Test
912+
public void testGetDailyDigestTimePreferenceWithoutIdentityIsRejected() {
913+
postMcp(null, GET_DAILY_DIGEST_TIME_PREFERENCE_BODY).statusCode(401);
914+
}
915+
916+
@Test
917+
public void testGetDailyDigestTimePreferenceWhenBackendReturns404() {
918+
MockServerLifecycleManager.getClient().stubFor(
919+
get(urlPathEqualTo("/api/notifications/v1.0/org-config/daily-digest/time-preference"))
920+
.willReturn(aResponse().withStatus(404))
921+
);
922+
923+
postMcp(validIdentity(), GET_DAILY_DIGEST_TIME_PREFERENCE_BODY)
924+
.statusCode(200)
925+
.body("result.isError", is(true))
926+
.body("result.content[0].text", containsString("Resource not found"));
927+
micrometerAssertionHelper.assertCounterIncrement(AUTH_SUCCESS_COUNTER, 1);
928+
}
929+
930+
// --- getUserNotificationPreferences tool tests ---
931+
932+
@Test
933+
public void testGetUserNotificationPreferencesWithValidIdentity() {
934+
String prefsJson = "{\"bundles\":{\"rhel\":{\"display_name\":\"Red Hat Enterprise Linux\",\"applications\":{}}}}";
935+
MockServerLifecycleManager.getClient().stubFor(
936+
get(urlPathEqualTo("/api/notifications/v1.0/user-config/notification-event-type-preference"))
937+
.withHeader("x-rh-identity", equalTo(validIdentity()))
938+
.willReturn(aResponse()
939+
.withHeader("Content-Type", "application/json")
940+
.withBody(prefsJson))
941+
);
942+
943+
postMcp(validIdentity(), GET_USER_NOTIFICATION_PREFERENCES_BODY)
944+
.statusCode(200)
945+
.body("result.content[0].text", containsString("rhel"))
946+
.body("result.content[0].text", containsString("Red Hat Enterprise Linux"));
947+
micrometerAssertionHelper.assertCounterIncrement(AUTH_SUCCESS_COUNTER, 1);
948+
949+
MockServerLifecycleManager.getClient().verify(
950+
getRequestedFor(urlPathEqualTo("/api/notifications/v1.0/user-config/notification-event-type-preference"))
951+
.withHeader("x-rh-identity", equalTo(validIdentity()))
952+
);
953+
}
954+
955+
@Test
956+
public void testGetUserNotificationPreferencesWithoutIdentityIsRejected() {
957+
postMcp(null, GET_USER_NOTIFICATION_PREFERENCES_BODY).statusCode(401);
958+
}
959+
960+
@Test
961+
public void testGetUserNotificationPreferencesWhenBackendReturns404() {
962+
MockServerLifecycleManager.getClient().stubFor(
963+
get(urlPathEqualTo("/api/notifications/v1.0/user-config/notification-event-type-preference"))
964+
.willReturn(aResponse().withStatus(404))
965+
);
966+
967+
postMcp(validIdentity(), GET_USER_NOTIFICATION_PREFERENCES_BODY)
968+
.statusCode(200)
969+
.body("result.isError", is(true))
970+
.body("result.content[0].text", containsString("Resource not found"));
971+
micrometerAssertionHelper.assertCounterIncrement(AUTH_SUCCESS_COUNTER, 1);
972+
}
973+
974+
// --- getUserNotificationPreferencesByApplication tool tests ---
975+
976+
@Test
977+
public void testGetUserNotificationPreferencesByApplicationWithValidIdentity() {
978+
String appPrefsJson = "{\"display_name\":\"Patch\",\"event_types\":{\"new-advisory\":{\"display_name\":\"New advisory\"}}}";
979+
MockServerLifecycleManager.getClient().stubFor(
980+
get(urlPathEqualTo("/api/notifications/v1.0/user-config/notification-event-type-preference/rhel/patch"))
981+
.withHeader("x-rh-identity", equalTo(validIdentity()))
982+
.willReturn(aResponse()
983+
.withHeader("Content-Type", "application/json")
984+
.withBody(appPrefsJson))
985+
);
986+
987+
postMcp(validIdentity(), GET_USER_NOTIFICATION_PREFERENCES_BY_APP_BODY)
988+
.statusCode(200)
989+
.body("result.content[0].text", containsString("Patch"))
990+
.body("result.content[0].text", containsString("New advisory"));
991+
micrometerAssertionHelper.assertCounterIncrement(AUTH_SUCCESS_COUNTER, 1);
992+
993+
MockServerLifecycleManager.getClient().verify(
994+
getRequestedFor(urlPathEqualTo("/api/notifications/v1.0/user-config/notification-event-type-preference/rhel/patch"))
995+
.withHeader("x-rh-identity", equalTo(validIdentity()))
996+
);
997+
}
998+
999+
@Test
1000+
public void testGetUserNotificationPreferencesByApplicationWithoutIdentityIsRejected() {
1001+
postMcp(null, GET_USER_NOTIFICATION_PREFERENCES_BY_APP_BODY).statusCode(401);
1002+
}
1003+
1004+
@Test
1005+
public void testGetUserNotificationPreferencesByApplicationWhenBackendReturns404() {
1006+
MockServerLifecycleManager.getClient().stubFor(
1007+
get(urlPathEqualTo("/api/notifications/v1.0/user-config/notification-event-type-preference/rhel/patch"))
1008+
.willReturn(aResponse().withStatus(404))
1009+
);
1010+
1011+
postMcp(validIdentity(), GET_USER_NOTIFICATION_PREFERENCES_BY_APP_BODY)
1012+
.statusCode(200)
1013+
.body("result.isError", is(true))
1014+
.body("result.content[0].text", containsString("Resource not found"));
1015+
micrometerAssertionHelper.assertCounterIncrement(AUTH_SUCCESS_COUNTER, 1);
1016+
}
1017+
7891018
// --- Health/metrics bypass tests ---
7901019

7911020
@Test

0 commit comments

Comments
 (0)