sap-ai-dev-toolkit 0.9.9922 → 0.9.9923

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.
Files changed (53) hide show
  1. package/.github/agents/sap-btp-infrastructure-admin.agent.md +23 -0
  2. package/.github/agents/sap-bw-datasphere-migration-specialist.agent.md +22 -0
  3. package/.github/agents/sap-datasphere-developer.agent.md +22 -0
  4. package/.github/agents/sap-devops-observability-specialist.agent.md +54 -0
  5. package/.github/agents/sap-process-automation-developer.agent.md +50 -0
  6. package/.github/skills/btp-platform-administration/SKILL.md +53 -0
  7. package/.github/skills/bw-datasphere-migration/SKILL.md +66 -0
  8. package/.github/skills/cloud-alm-operations/SKILL.md +135 -0
  9. package/.github/skills/datasphere-administration/SKILL.md +40 -0
  10. package/.github/skills/datasphere-development/SKILL.md +55 -0
  11. package/.github/skills/process-automation-development/SKILL.md +113 -0
  12. package/README.md +152 -8
  13. package/inventory.md +60 -0
  14. package/package.json +8 -2
  15. package/src/btp-cli.mjs +199 -0
  16. package/src/btp-server.mjs +125 -0
  17. package/src/btp-tools.mjs +776 -0
  18. package/src/cloud-alm-client.mjs +44 -0
  19. package/src/cloud-alm-config.mjs +92 -0
  20. package/src/cloud-alm-credentials.mjs +29 -0
  21. package/src/cloud-alm-data.mjs +129 -0
  22. package/src/cloud-alm-server.mjs +115 -0
  23. package/src/cloud-alm-tools.mjs +682 -0
  24. package/src/datasphere-auth.mjs +279 -0
  25. package/src/datasphere-cli.mjs +220 -0
  26. package/src/datasphere-command.mjs +236 -0
  27. package/src/datasphere-config.mjs +154 -0
  28. package/src/datasphere-credentials.mjs +37 -0
  29. package/src/datasphere-csn.mjs +73 -0
  30. package/src/datasphere-discovery.mjs +151 -0
  31. package/src/datasphere-server.mjs +249 -0
  32. package/src/datasphere-tools.mjs +809 -0
  33. package/src/hana-database.mjs +3 -3
  34. package/src/hana-inspector.mjs +2 -2
  35. package/src/harnesses.mjs +31 -1
  36. package/src/install-user-assets.mjs +1 -1
  37. package/src/integration-common.mjs +7 -7
  38. package/src/integration-config.mjs +5 -0
  39. package/src/integration-credentials.mjs +25 -16
  40. package/src/integration-http.mjs +24 -7
  41. package/src/launcher.mjs +56 -4
  42. package/src/mcp-config.mjs +37 -1
  43. package/src/process-automation-automation-tools.mjs +125 -0
  44. package/src/process-automation-client.mjs +36 -0
  45. package/src/process-automation-common.mjs +69 -0
  46. package/src/process-automation-config.mjs +142 -0
  47. package/src/process-automation-credentials.mjs +37 -0
  48. package/src/process-automation-inbox-tools.mjs +246 -0
  49. package/src/process-automation-server.mjs +139 -0
  50. package/src/process-automation-tools.mjs +727 -0
  51. package/src/process-automation-visibility-tools.mjs +182 -0
  52. package/src/setup.mjs +623 -11
  53. package/tools.md +138 -0
@@ -0,0 +1,682 @@
1
+ // MCP tools for SAP Cloud ALM operations: landscape, Integration & Exception
2
+ // Monitoring records (Raw Data Outbound Logs), analytics, health/alert hotspots
3
+ // per landscape tier, incident triage with CPI and SLG1 handoffs, feature and
4
+ // transport status, and the one write: closing an event situation behind an
5
+ // exact confirmation string. Every result passes through secret redaction.
6
+ import { z } from 'zod';
7
+ import {
8
+ OVERWRITE_ANNOTATIONS,
9
+ READ_ONLY_ANNOTATIONS,
10
+ confirmation,
11
+ isoDate,
12
+ list,
13
+ pick,
14
+ redactSecrets,
15
+ requireConfirmation,
16
+ resultOf
17
+ } from './integration-common.mjs';
18
+ import { IntegrationError, odataKey } from './integration-http.mjs';
19
+ import { CLOUD_ALM_PATHS } from './cloud-alm-client.mjs';
20
+ import { aggregateAnalyticsRows, flattenLogRecords, lookbackWindow, readAnalyticsTable } from './cloud-alm-data.mjs';
21
+
22
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
23
+ const lmsId = z.string().regex(UUID, 'expected a Cloud ALM landscape (LMS) id (UUID)');
24
+ const uuid = z.string().regex(UUID, 'expected a UUID');
25
+ const providerName = z.string().regex(/^[A-Za-z][A-Za-z0-9_.-]{0,99}$/);
26
+ const providerVersion = z.string().regex(/^v?\d{1,3}$/i);
27
+ const label = z.string().min(1).max(120);
28
+ const end = isoDate.optional().describe('UTC end of the window (default now).');
29
+ const OBJECT_TYPE = z.enum(['CloudService', 'TechnicalSystem', 'BusinessService']);
30
+ const RECORD_PROVIDER = z.enum(['im', 'exm.im', 'exm.jam', 'iep', 'jam.run', 'bsm']);
31
+ const JOB_TYPE = z.enum(['CF_DM_PROCESS', 'HCP_IPS_JOB', 'IRPA', 'NW_ABAP_JOB', 'NW_ABAP_PC', 'NW_ABAP_WF', 'NW_APPL_JOB', 'SPA_AUTOMATION', 'SPA_PROCESS']);
32
+ const USE_CASE = z.enum(['CALM.EXM', 'CALM.IM', 'CALM.BPMON', 'CALM.BSM', 'CALM.HM', 'CALM.JM', 'CALM.SUM']);
33
+ const analyticsFilters = z.array(z.object({
34
+ key: z.string().min(1).max(100),
35
+ values: z.array(z.string().max(255)).min(1).max(50)
36
+ })).max(10);
37
+
38
+ const LANDSCAPE_FIELDS = 'lmsId,name,description,objectType,source,systemNumber,systemType,role,serviceType,serviceTypeLabel,externalId,datacenter,mainUrl,logicalSystems'.split(',');
39
+ const FEATURE_SELECT = 'uuid,displayId,title,projectId,statusCode,priorityCode,releaseId,modifiedAt';
40
+ const FEATURE_EXPAND = 'toStatus,toPriority,toProject,toRelease,toTransports,toTransportReferences';
41
+ const FULL_EXPAND = 'toStatus,toPriority,toProject,toScope,toRelease,toWorkstream,toTransports,toTransportReferences,toTaskAssignments,toURLReferences,toExternalReferences';
42
+ const HEALTH_SERVICE_LIMIT = 30;
43
+ const LANDSCAPE_PAGE = 500;
44
+
45
+ // Which record filters apply to which provider.
46
+ const RECORD_FILTERS = Object.freeze({
47
+ categories: ['im', 'exm.im', 'exm.jam'],
48
+ status: ['exm.im', 'exm.jam'],
49
+ message: ['exm.im', 'exm.jam'],
50
+ usecase: ['iep'],
51
+ event_subtype: ['iep'],
52
+ job_type: ['jam.run'],
53
+ job_name: ['jam.run'],
54
+ event_domain: ['bsm']
55
+ });
56
+
57
+ export const CLOUD_ALM_TOOL_NAMES = Object.freeze([
58
+ 'calm_connection_info',
59
+ 'calm_list_landscape',
60
+ 'calm_get_landscape_health',
61
+ 'calm_list_monitoring_records',
62
+ 'calm_triage_incident',
63
+ 'calm_list_analytics_providers',
64
+ 'calm_describe_analytics_provider',
65
+ 'calm_query_analytics',
66
+ 'calm_list_features',
67
+ 'calm_get_feature',
68
+ 'calm_close_event_situation'
69
+ ]);
70
+
71
+ const rows = value => (Array.isArray(value) ? value : []);
72
+ const enc = encodeURIComponent;
73
+ const utcDate = ms => new Date(ms).toISOString().slice(0, 10);
74
+ const projectLandscape = row => pick(row, LANDSCAPE_FIELDS);
75
+
76
+ function projectFeature(row) {
77
+ return {
78
+ uuid: row?.uuid,
79
+ display_id: row?.displayId,
80
+ title: row?.title,
81
+ status_code: row?.statusCode,
82
+ status: row?.toStatus?.name,
83
+ priority: row?.toPriority?.name ?? row?.priorityCode,
84
+ project: row?.toProject?.name,
85
+ project_id: row?.projectId,
86
+ release: row?.toRelease?.name,
87
+ modified_at: row?.modifiedAt,
88
+ transports: rows(row?.toTransports).map(entry => entry?.id),
89
+ transport_references: rows(row?.toTransportReferences).map(entry => entry?.id)
90
+ };
91
+ }
92
+
93
+ function featureDetail(row) {
94
+ const description = typeof row?.description === 'string' ? row.description.slice(0, 4096) : row?.description;
95
+ return {
96
+ feature: { ...projectFeature(row), description },
97
+ task_assignments: rows(row?.toTaskAssignments).map(entry => pick(entry, ['uuid', 'title', 'type'])),
98
+ url_references: rows(row?.toURLReferences).map(entry => pick(entry, ['name', 'url'])),
99
+ external_references: rows(row?.toExternalReferences).map(entry => pick(entry, ['id', 'name', 'url']))
100
+ };
101
+ }
102
+
103
+ function validateRecordsInput(args) {
104
+ const { provider } = args;
105
+ for (const [field, providers] of Object.entries(RECORD_FILTERS)) {
106
+ if (args[field] !== undefined && !providers.includes(provider)) {
107
+ throw new IntegrationError(`${field} applies only to provider ${providers.join(' or ')}`);
108
+ }
109
+ }
110
+ if (provider === 'im' && !args.categories) {
111
+ throw new IntegrationError('im records need categories: the labels shown in Integration Monitoring, for example "SAP Integration Suite Messages" or "IDoc"');
112
+ }
113
+ if ((provider === 'iep' || provider === 'bsm') && args.service_ids.length > 1) {
114
+ throw new IntegrationError(`${provider} accepts exactly one service id`);
115
+ }
116
+ if (provider === 'jam.run' && args.service_ids.length > 27) throw new IntegrationError('jam.run accepts at most 27 service ids');
117
+ if (args.minutes > 60 && provider !== 'bsm') {
118
+ throw new IntegrationError(`${provider} records are limited to 60-minute windows; only bsm accepts up to 31 days`);
119
+ }
120
+ }
121
+
122
+ function recordFilters(args) {
123
+ const filters = {
124
+ category: args.categories?.join(','),
125
+ status: args.status,
126
+ message: args.message,
127
+ usecase: args.usecase,
128
+ eventsubtype: args.event_subtype,
129
+ jobType: args.job_type,
130
+ jobName: args.job_name,
131
+ eventDomain: args.event_domain
132
+ };
133
+ return Object.fromEntries(Object.entries(filters).filter(([, value]) => value !== undefined));
134
+ }
135
+
136
+ // Aggregated rows keep dimension values first and measures after, in query column order.
137
+ const values = row => Object.values(row);
138
+ const sum = numbers => numbers.reduce((total, value) => total + (Number.isFinite(value) ? value : 0), 0);
139
+
140
+ function alertSummary(table, top) {
141
+ const grouped = aggregateAnalyticsRows(table, 'sum').map(values)
142
+ .map(([useCase, eventName, events, alerts, situations]) => ({ use_case: useCase, event_name: eventName, events, alerts, event_situations: situations }));
143
+ return {
144
+ events: sum(grouped.map(entry => entry.events)),
145
+ alerts: sum(grouped.map(entry => entry.alerts)),
146
+ event_situations: sum(grouped.map(entry => entry.event_situations)),
147
+ grouped: grouped.slice(0, top)
148
+ };
149
+ }
150
+
151
+ function tierOrder(roles) {
152
+ if (roles) {
153
+ const wanted = roles.map(role => role.toUpperCase());
154
+ return (a, b) => wanted.indexOf(a.toUpperCase()) - wanted.indexOf(b.toUpperCase());
155
+ }
156
+ return (a, b) => (a === 'UNASSIGNED') - (b === 'UNASSIGNED') || a.localeCompare(b);
157
+ }
158
+
159
+ /** Register all SAP Cloud ALM tools; returns the registered names. */
160
+ export function registerCloudAlmTools(server, { rest, summary, verify, now = Date.now } = {}) {
161
+ const registeredNames = [];
162
+ const register = (name, title, description, inputSchema, annotations, handler) => {
163
+ registeredNames.push(name);
164
+ server.registerTool(name, { title, description, inputSchema, annotations }, async args => resultOf(
165
+ async () => redactSecrets(await handler(args)),
166
+ { product: 'SAP Cloud ALM', fallback: 'SAP Cloud ALM request failed. Check the service key scopes (calm-api.*) and API reachability.' }
167
+ ));
168
+ };
169
+
170
+ const analyticsData = async (queries, window, resolution) => rows(await rest.request('POST', `${CLOUD_ALM_PATHS.analytics}/providers/data`, {
171
+ json: {
172
+ format: 'table',
173
+ tableType: 'raw',
174
+ timestampFormat: 'unix',
175
+ timeRange: { from: window.from, to: window.to },
176
+ resolution,
177
+ timezone: '+00:00',
178
+ queries
179
+ },
180
+ expect: [200]
181
+ })).map(readAnalyticsTable);
182
+
183
+ const fetchRecords = async ({ provider, serviceIds, filters = {}, window, limit, offset }) => {
184
+ // Comma lists are joined strings: arrays would become repeated parameters.
185
+ const response = await rest.request('GET', CLOUD_ALM_PATHS.logs, {
186
+ query: {
187
+ provider,
188
+ format: 'protobuf-json',
189
+ from: window.from,
190
+ to: window.to,
191
+ limit,
192
+ offset,
193
+ onLimit: 'TRUNCATE',
194
+ serviceId: serviceIds.join(','),
195
+ ...(provider === 'im' ? { version: 'v2' } : {}),
196
+ ...filters
197
+ },
198
+ responseHeaders: ['x-total-count']
199
+ });
200
+ const records = flattenLogRecords(response?.data);
201
+ const header = response?.responseHeaders?.['x-total-count'];
202
+ const total = header === undefined || header === null || header === '' ? null : Number(header);
203
+ const totalCount = Number.isFinite(total) ? total : null;
204
+ const returned = records.length;
205
+ let nextOffset;
206
+ if (totalCount !== null) nextOffset = offset + returned < totalCount ? offset + returned : null;
207
+ else nextOffset = returned === limit ? offset + limit : null;
208
+ return { total_count: totalCount, returned, next_offset: nextOffset, records };
209
+ };
210
+
211
+ const landscapeObject = async id => {
212
+ const found = list(await rest.request('GET', CLOUD_ALM_PATHS.landscape, { query: { lmsId: id } }))[0];
213
+ if (!found) throw new IntegrationError(`Cloud ALM landscape object ${id} was not found`);
214
+ return projectLandscape(found);
215
+ };
216
+
217
+ // ------------------------------------------------------------ connection
218
+
219
+ register('calm_connection_info', 'Cloud ALM connection info',
220
+ 'Report the connected Cloud ALM API host, token host, auth mode and credential source; verify: true also checks Landscape and Analytics API access read-only and never returns credentials.',
221
+ { verify: z.boolean().default(false) },
222
+ READ_ONLY_ANNOTATIONS,
223
+ async ({ verify: online }) => ({ ...summary, ...(online && verify ? { verification: await verify() } : {}) }));
224
+
225
+ // ------------------------------------------------------------ landscape
226
+
227
+ register('calm_list_landscape', 'List Cloud ALM landscape objects',
228
+ 'List Cloud ALM landscape objects (cloud services, technical systems, business services) with their LMS id, role (tenant-defined tier such as DEV/TEST/PROD), service type, system number and logical systems, up to 500 per page.',
229
+ {
230
+ object_type: OBJECT_TYPE.optional(),
231
+ role: z.string().min(1).max(40).optional(),
232
+ service_type: z.string().min(1).max(100).optional(),
233
+ name: z.string().min(1).max(255).optional(),
234
+ system_number: z.string().min(1).max(64).optional(),
235
+ lms_id: lmsId.optional(),
236
+ external_id: z.string().min(1).max(255).optional(),
237
+ limit: z.number().int().min(1).max(500).default(100),
238
+ offset: z.number().int().min(0).default(0)
239
+ },
240
+ READ_ONLY_ANNOTATIONS,
241
+ async ({ object_type, role, service_type, name, system_number, lms_id, external_id, limit, offset }) => {
242
+ const objects = list(await rest.request('GET', CLOUD_ALM_PATHS.landscape, {
243
+ query: { objectType: object_type, role, serviceType: service_type, name, systemNumber: system_number, lmsId: lms_id, externalId: external_id, limit, offset }
244
+ })).map(projectLandscape);
245
+ return { objects, returned: objects.length, next_offset: objects.length === limit ? offset + limit : null };
246
+ });
247
+
248
+ register('calm_get_landscape_health', 'Cloud ALM landscape health per tier',
249
+ 'Summarize exception counts (EXM), critical/warning health metrics (Health Monitoring) and monitoring events/alerts (EVENTS analytics) for at most 30 landscape services grouped by role tier over the last 1 to 744 hours; each source reports its own failure instead of failing the whole result.',
250
+ {
251
+ roles: z.array(z.string().min(1).max(40)).min(1).max(10).optional().describe('Tenant role values to include, in tier order (case-insensitive), for example ["DEV","TEST","PROD"].'),
252
+ object_type: OBJECT_TYPE.optional(),
253
+ service_type: z.string().min(1).max(100).optional(),
254
+ name: z.string().min(1).max(255).optional(),
255
+ lms_ids: z.array(lmsId).min(1).max(30).optional(),
256
+ hours: z.number().int().min(1).max(744).default(24),
257
+ end
258
+ },
259
+ READ_ONLY_ANNOTATIONS,
260
+ async ({ roles, object_type, service_type, name, lms_ids, hours, end: endAt }) => {
261
+ let selected = [];
262
+ const missing = [];
263
+ let landscapeTruncated = false;
264
+ if (lms_ids) {
265
+ for (const id of lms_ids) {
266
+ const found = list(await rest.request('GET', CLOUD_ALM_PATHS.landscape, { query: { lmsId: id } }))[0];
267
+ if (found) selected.push(found);
268
+ else missing.push(id);
269
+ }
270
+ } else {
271
+ selected = list(await rest.request('GET', CLOUD_ALM_PATHS.landscape, {
272
+ query: { objectType: object_type, serviceType: service_type, name, limit: LANDSCAPE_PAGE }
273
+ }));
274
+ landscapeTruncated = selected.length === LANDSCAPE_PAGE;
275
+ }
276
+ if (roles) {
277
+ const wanted = new Set(roles.map(role => role.toUpperCase()));
278
+ selected = selected.filter(object => wanted.has(String(object?.role ?? '').toUpperCase()));
279
+ }
280
+ const window = lookbackWindow({ end: endAt, spanMs: hours * 3_600_000, now });
281
+ const resolution = hours <= 48 ? 'H' : 'D';
282
+ const windowOut = { from: window.from_iso, to: window.to_iso, resolution };
283
+ if (selected.length > HEALTH_SERVICE_LIMIT) {
284
+ throw new IntegrationError(`landscape health covers at most ${HEALTH_SERVICE_LIMIT} services; ${selected.length} matched; narrow with roles, object_type, service_type, name or lms_ids`);
285
+ }
286
+ const extras = {
287
+ ...(landscapeTruncated ? { landscape_truncated: true } : {}),
288
+ ...(missing.length ? { missing_lms_ids: missing } : {})
289
+ };
290
+ if (!selected.length) return { window: windowOut, matched: 0, ...extras, tiers: [] };
291
+
292
+ const ids = selected.map(object => object.lmsId);
293
+ const sources = {};
294
+ const exceptionsById = new Map();
295
+ const healthById = new Map();
296
+ const alertsById = new Map();
297
+
298
+ try {
299
+ const [table] = await analyticsData([{
300
+ name: 'exceptions',
301
+ provider: 'EXM_DATAPROVIDER',
302
+ columns: { dimensions: ['serviceId', 'categoryName'], metrics: [{ measure: 'counter', method: 'SUM' }] },
303
+ filters: [{ key: 'serviceId', values: ids }]
304
+ }], window, resolution);
305
+ for (const [serviceId, category, count] of aggregateAnalyticsRows(table ?? { columns: [], rows: [] }, 'sum').map(values)) {
306
+ const entry = exceptionsById.get(serviceId) ?? [];
307
+ entry.push({ category, count });
308
+ exceptionsById.set(serviceId, entry);
309
+ }
310
+ sources.exceptions = { ok: true, truncated: Boolean(table?.truncated) };
311
+ } catch (error) {
312
+ sources.exceptions = { ok: false, error: error?.message ?? String(error) };
313
+ }
314
+
315
+ try {
316
+ const tables = await analyticsData(ids.map(id => ({
317
+ name: id,
318
+ provider: 'HM_ANALYTICS_DP',
319
+ columns: { dimensions: ['metricName'], metrics: [{ measure: 'criticalStatus' }, { measure: 'warningStatus' }] },
320
+ filters: [{ key: 'serviceId', values: [id] }]
321
+ })), window, resolution);
322
+ ids.forEach((id, index) => {
323
+ const table = tables[index] ?? { columns: [], rows: [] };
324
+ const metrics = aggregateAnalyticsRows(table, 'max').map(values);
325
+ const critical = metrics.filter(([, criticalStatus]) => criticalStatus > 0).map(([metric]) => metric);
326
+ const warning = metrics.filter(([, criticalStatus, warningStatus]) => !(criticalStatus > 0) && warningStatus > 0).map(([metric]) => metric);
327
+ healthById.set(id, { critical: critical.slice(0, 25), warning: warning.slice(0, 25) });
328
+ });
329
+ sources.health = { ok: true, truncated: tables.some(table => table?.truncated) };
330
+ } catch (error) {
331
+ sources.health = { ok: false, error: error?.message ?? String(error) };
332
+ }
333
+
334
+ try {
335
+ const tables = await analyticsData(ids.map(id => ({
336
+ name: id,
337
+ provider: 'EVENTS',
338
+ columns: { dimensions: ['useCase', 'eventName'], metrics: [{ measure: 'events' }, { measure: 'alerts' }, { measure: 'eventSituations' }] },
339
+ filters: [{ key: 'serviceId', values: [id] }]
340
+ })), window, resolution);
341
+ ids.forEach((id, index) => {
342
+ const { grouped, ...totals } = alertSummary(tables[index] ?? { columns: [], rows: [] }, 10);
343
+ alertsById.set(id, { ...totals, top: grouped.map(({ use_case, event_name, events, alerts }) => ({ use_case, event_name, events, alerts })) });
344
+ });
345
+ sources.alerts = { ok: true, truncated: tables.some(table => table?.truncated) };
346
+ } catch (error) {
347
+ sources.alerts = { ok: false, error: error?.message ?? String(error) };
348
+ }
349
+
350
+ const byTier = new Map();
351
+ for (const object of selected) {
352
+ const service = {
353
+ lms_id: object.lmsId,
354
+ name: object.name,
355
+ object_type: object.objectType,
356
+ service_type: object.serviceType,
357
+ system_number: object.systemNumber
358
+ };
359
+ if (sources.exceptions.ok) {
360
+ const categories = (exceptionsById.get(object.lmsId) ?? []).sort((a, b) => (b.count ?? 0) - (a.count ?? 0));
361
+ service.exceptions = { total: sum(categories.map(entry => entry.count)), by_category: categories.slice(0, 20) };
362
+ }
363
+ if (sources.health.ok) service.health = healthById.get(object.lmsId);
364
+ if (sources.alerts.ok) service.alerts = alertsById.get(object.lmsId);
365
+ const tier = object.role || 'UNASSIGNED';
366
+ if (!byTier.has(tier)) byTier.set(tier, []);
367
+ byTier.get(tier).push(service);
368
+ }
369
+ const tiers = [...byTier.keys()].sort(tierOrder(roles)).map(role => {
370
+ const services = byTier.get(role);
371
+ return {
372
+ role,
373
+ totals: {
374
+ services: services.length,
375
+ exceptions: sources.exceptions.ok ? sum(services.map(service => service.exceptions.total)) : null,
376
+ services_with_critical_metrics: sources.health.ok ? services.filter(service => service.health.critical.length).length : null,
377
+ alerts: sources.alerts.ok ? sum(services.map(service => service.alerts.alerts)) : null
378
+ },
379
+ services
380
+ };
381
+ });
382
+ return { window: windowOut, matched: selected.length, ...extras, sources, tiers };
383
+ });
384
+
385
+ // ------------------------------------------------------------ records
386
+
387
+ register('calm_list_monitoring_records', 'List Cloud ALM monitoring records',
388
+ 'Read raw Cloud ALM monitoring records (Raw Data Outbound Logs) for integration messages (im), exceptions (exm.im, exm.jam), monitoring events (iep), job runs (jam.run) or business service events (bsm) over windows of at most 60 minutes (bsm up to 31 days), up to 200 records per page with next_offset paging.',
389
+ {
390
+ provider: RECORD_PROVIDER,
391
+ service_ids: z.array(lmsId).min(1).max(30),
392
+ categories: z.array(label).min(1).max(20).optional().describe('Integration/exception category labels as shown in the Cloud ALM UI; required for im.'),
393
+ status: z.enum(['Resolved', 'Warning', 'Error']).optional(),
394
+ message: z.string().min(1).max(255).optional().describe('Message text filter; * is a wildcard.'),
395
+ usecase: USE_CASE.optional(),
396
+ event_subtype: z.string().min(1).max(120).optional().describe('Event subtype, for example calm.im.cpi.msg.'),
397
+ job_type: JOB_TYPE.optional(),
398
+ job_name: z.string().min(1).max(255).optional(),
399
+ event_domain: z.enum(['Maintenance', 'Degradation', 'Disruption', 'Communication']).optional(),
400
+ end,
401
+ minutes: z.number().int().min(1).max(44_640).default(30),
402
+ limit: z.number().int().min(1).max(200).default(50),
403
+ offset: z.number().int().min(0).default(0)
404
+ },
405
+ READ_ONLY_ANNOTATIONS,
406
+ async args => {
407
+ validateRecordsInput(args);
408
+ const window = lookbackWindow({ end: args.end, spanMs: args.minutes * 60_000, now });
409
+ const page = await fetchRecords({
410
+ provider: args.provider,
411
+ serviceIds: args.service_ids,
412
+ filters: recordFilters(args),
413
+ window,
414
+ limit: args.limit,
415
+ offset: args.offset
416
+ });
417
+ return { provider: args.provider, window: { from: window.from_iso, to: window.to_iso }, ...page };
418
+ });
419
+
420
+ register('calm_triage_incident', 'Triage a Cloud ALM incident',
421
+ 'Collect, for one landscape service over the last 1 to 60 minutes, monitoring event and alert counts, monitoring event records (iep), exception records with status Error (exm.im) and optionally integration message records (im), plus ready-to-use argument handoffs for cpi_list_message_logs and the ABAP get_application_log (SLG1) tool.',
422
+ {
423
+ service_id: lmsId,
424
+ end,
425
+ minutes: z.number().int().min(1).max(60).default(30),
426
+ im_categories: z.array(label).min(1).max(20).optional().describe('Integration Monitoring category labels, for example "SAP Integration Suite Messages" or "IDoc".'),
427
+ limit_per_section: z.number().int().min(1).max(100).default(25)
428
+ },
429
+ READ_ONLY_ANNOTATIONS,
430
+ async ({ service_id, end: endAt, minutes, im_categories, limit_per_section: limit }) => {
431
+ const service = await landscapeObject(service_id);
432
+ const window = lookbackWindow({ end: endAt, spanMs: minutes * 60_000, now });
433
+ const section = async operation => {
434
+ try {
435
+ return await operation();
436
+ } catch (error) {
437
+ return { error: error?.message ?? String(error) };
438
+ }
439
+ };
440
+ const records = (provider, filters) => fetchRecords({ provider, serviceIds: [service_id], filters, window, limit, offset: 0 });
441
+
442
+ const alertCounts = await section(async () => {
443
+ const [table] = await analyticsData([{
444
+ name: 'alerts',
445
+ provider: 'EVENTS',
446
+ columns: { dimensions: ['useCase', 'eventName'], metrics: [{ measure: 'events' }, { measure: 'alerts' }, { measure: 'eventSituations' }] },
447
+ filters: [{ key: 'serviceId', values: [service_id] }]
448
+ }], window, '5Mi');
449
+ const { grouped, ...totals } = alertSummary(table ?? { columns: [], rows: [] }, 20);
450
+ return { ...totals, by_event: grouped };
451
+ });
452
+ const alerts = await section(() => records('iep'));
453
+ const exceptions = await section(() => records('exm.im', { status: 'Error' }));
454
+ const messages = im_categories
455
+ ? await section(() => records('im', { category: im_categories.join(',') }))
456
+ : { skipped: 'pass im_categories (for example "SAP Integration Suite Messages", "IDoc") to include integration messages' };
457
+
458
+ const handoff = {
459
+ utc_window: { from: window.from_iso, to: window.to_iso },
460
+ cpi: { tool: 'cpi_list_message_logs', arguments: { from: window.from_iso, to: window.to_iso } },
461
+ abap: {
462
+ tool: 'get_application_log',
463
+ system: service.name,
464
+ system_number: service.systemNumber,
465
+ clients: rows(service.logicalSystems).map(entry => entry?.client).filter(client => client !== undefined && client !== null),
466
+ arguments: { from: utcDate(window.startMs - 86_400_000), to: utcDate(window.endMs + 86_400_000), messages: true }
467
+ }
468
+ };
469
+ return {
470
+ service,
471
+ window: { from: window.from_iso, to: window.to_iso },
472
+ alert_counts: alertCounts,
473
+ alerts,
474
+ exceptions,
475
+ messages,
476
+ handoff
477
+ };
478
+ });
479
+
480
+ // ------------------------------------------------------------ analytics
481
+
482
+ register('calm_list_analytics_providers', 'List Cloud ALM analytics providers',
483
+ 'List the Cloud ALM analytics data providers the service key is authorized for, with domain, service and versions.',
484
+ {},
485
+ READ_ONLY_ANNOTATIONS,
486
+ async () => ({
487
+ providers: list(await rest.request('GET', `${CLOUD_ALM_PATHS.analytics}/providers`))
488
+ .map(provider => pick(provider, ['name', 'description', 'domain', 'service', 'version']))
489
+ }));
490
+
491
+ register('calm_describe_analytics_provider', 'Describe a Cloud ALM analytics provider',
492
+ 'Describe the dimensions, measures and attributes of one Cloud ALM analytics provider, or with attribute set list that attribute\'s values (first 100), optionally narrowed by filters.',
493
+ {
494
+ provider: providerName,
495
+ version: providerVersion.optional(),
496
+ attribute: z.string().min(1).max(100).optional(),
497
+ filters: analyticsFilters.optional()
498
+ },
499
+ READ_ONLY_ANNOTATIONS,
500
+ async ({ provider, version, attribute, filters }) => {
501
+ const fields = list(await rest.request('POST', `${CLOUD_ALM_PATHS.analytics}/providers/filters`, {
502
+ json: { providerName: provider, providerVersion: version ?? '', ...(attribute ? { name: attribute, filters: filters ?? [] } : {}) },
503
+ expect: [200]
504
+ }));
505
+ return {
506
+ provider,
507
+ fields: fields.map(field => {
508
+ const fieldValues = rows(field?.values);
509
+ return {
510
+ key: field?.key,
511
+ name: field?.name,
512
+ description: field?.description,
513
+ type: field?.type,
514
+ filterable: field?.isAttribute === true || field?.type === 'attribute',
515
+ multiple: field?.isMultiple === true,
516
+ values: fieldValues.slice(0, 100).map(value => pick(value, ['key', 'label'])),
517
+ values_total: fieldValues.length
518
+ };
519
+ })
520
+ };
521
+ });
522
+
523
+ register('calm_query_analytics', 'Query Cloud ALM analytics',
524
+ 'Run one Cloud ALM analytics query (provider, dimensions, measures, filters) over the last 1 to 744 hours and return up to 500 rows, summed or maxed per dimension combination by default or raw per time bucket with aggregate none.',
525
+ {
526
+ provider: providerName,
527
+ version: providerVersion.optional(),
528
+ dimensions: z.array(z.string().min(1).max(100)).max(10).default([]),
529
+ measures: z.array(z.object({
530
+ measure: z.string().min(1).max(100),
531
+ method: z.enum(['SUM', 'AVG', 'MIN', 'MAX']).optional()
532
+ })).min(1).max(10),
533
+ filters: analyticsFilters.default([]),
534
+ hours: z.number().int().min(1).max(744).default(24),
535
+ end,
536
+ resolution: z.enum(['P', '1Mi', '5Mi', '10Mi', '15Mi', '30Mi', 'H', 'D', 'W', 'M']).default('H'),
537
+ aggregate: z.enum(['none', 'sum', 'max']).default('sum').describe('sum for counts, max for percentage measures such as Health Monitoring status.'),
538
+ limit: z.number().int().min(1).max(500).default(200)
539
+ },
540
+ READ_ONLY_ANNOTATIONS,
541
+ async ({ provider, version, dimensions, measures, filters, hours, end: endAt, resolution, aggregate, limit }) => {
542
+ const window = lookbackWindow({ end: endAt, spanMs: hours * 3_600_000, now });
543
+ const [table = readAnalyticsTable({})] = await analyticsData([{
544
+ name: 'query',
545
+ provider,
546
+ ...(version ? { version } : {}),
547
+ columns: { dimensions, metrics: measures },
548
+ filters
549
+ }], window, resolution);
550
+ const result = aggregate === 'none' ? table.rows : aggregateAnalyticsRows(table, aggregate);
551
+ const page = result.slice(0, limit);
552
+ return {
553
+ provider,
554
+ window: { from: window.from_iso, to: window.to_iso, resolution },
555
+ aggregate,
556
+ total_rows: table.total ?? result.length,
557
+ returned: page.length,
558
+ truncated: table.truncated || result.length > limit,
559
+ rows: page
560
+ };
561
+ });
562
+
563
+ // ------------------------------------------------------------ features
564
+
565
+ register('calm_list_features', 'List Cloud ALM features',
566
+ 'List Cloud ALM features (newest change first, up to 200 per page) with lifecycle status, priority, project, release and the ids of their transports, optionally filtered by project, release, status codes and change date.',
567
+ {
568
+ project_id: uuid.optional(),
569
+ release_id: uuid.optional(),
570
+ status_codes: z.array(z.string().regex(/^[A-Z][A-Z0-9_]{0,59}$/)).min(1).max(10).optional()
571
+ .describe('For example IN_REALIZATION, IN_TESTING, SUCCESSFULLY_TESTED, APPROVED_FOR_DEPLOYMENT, CONFIRMED.'),
572
+ modified_since: isoDate.optional(),
573
+ top: z.number().int().min(1).max(200).default(50),
574
+ skip: z.number().int().min(0).default(0),
575
+ include_status_catalog: z.boolean().default(false)
576
+ },
577
+ READ_ONLY_ANNOTATIONS,
578
+ async ({ project_id, release_id, status_codes, modified_since, top, skip, include_status_catalog }) => {
579
+ const clauses = [];
580
+ if (status_codes) clauses.push(`(${status_codes.map(code => `statusCode eq ${odataKey(code)}`).join(' or ')})`);
581
+ if (project_id) clauses.push(`projectId eq ${project_id}`);
582
+ if (release_id) clauses.push(`releaseId eq ${release_id}`);
583
+ if (modified_since) clauses.push(`modifiedAt ge ${modified_since}`);
584
+ const response = await rest.request('GET', `${CLOUD_ALM_PATHS.features}/Features`, {
585
+ query: {
586
+ ...(clauses.length ? { $filter: clauses.join(' and ') } : {}),
587
+ $select: FEATURE_SELECT,
588
+ $expand: FEATURE_EXPAND,
589
+ $orderby: 'modifiedAt desc',
590
+ $top: top,
591
+ $skip: skip,
592
+ $count: true
593
+ }
594
+ });
595
+ return {
596
+ features: list(response).map(projectFeature),
597
+ count: response?.['@odata.count'] ?? response?.['@count'] ?? null,
598
+ ...(include_status_catalog ? { status_catalog: list(await rest.request('GET', `${CLOUD_ALM_PATHS.features}/FeatureStatus`)) } : {})
599
+ };
600
+ });
601
+
602
+ register('calm_get_feature', 'Get a Cloud ALM feature',
603
+ 'Read one Cloud ALM feature by UUID or display id with status, transports, task assignments and references, or find the features linked to a transport id.',
604
+ z.object({
605
+ feature_uuid: uuid.optional(),
606
+ display_id: z.string().regex(/^\d+-\d+$/).optional(),
607
+ transport_id: z.string().regex(/^[A-Za-z0-9_.-]{1,64}$/).optional()
608
+ }).refine(
609
+ args => [args.feature_uuid, args.display_id, args.transport_id].filter(value => value !== undefined).length === 1,
610
+ 'provide exactly one of feature_uuid, display_id, transport_id'
611
+ ),
612
+ READ_ONLY_ANNOTATIONS,
613
+ async ({ feature_uuid, display_id, transport_id }) => {
614
+ if (feature_uuid) {
615
+ return featureDetail(await rest.request('GET', `${CLOUD_ALM_PATHS.features}/Features/${enc(feature_uuid)}`, { query: { $expand: FULL_EXPAND } }));
616
+ }
617
+ if (display_id) {
618
+ const matches = list(await rest.request('GET', `${CLOUD_ALM_PATHS.features}/Features`, {
619
+ query: { $filter: `displayId eq ${odataKey(display_id)}`, $expand: FULL_EXPAND, $top: 2 }
620
+ }));
621
+ if (!matches.length) throw new IntegrationError(`no Cloud ALM feature has display id ${display_id}`);
622
+ if (matches.length > 1) throw new IntegrationError(`display id ${display_id} matched ${matches.length} features`);
623
+ return featureDetail(matches[0]);
624
+ }
625
+ const features = new Map();
626
+ for (const [entitySet, link] of [['Transports', 'transport'], ['TransportReferences', 'transport_reference']]) {
627
+ const links = list(await rest.request('GET', `${CLOUD_ALM_PATHS.features}/${entitySet}`, {
628
+ query: { $filter: `id eq ${odataKey(transport_id)}`, $expand: 'parent' }
629
+ }));
630
+ for (const parent of links.map(entry => entry?.parent)) {
631
+ if (!parent?.uuid || features.has(parent.uuid)) continue;
632
+ features.set(parent.uuid, {
633
+ link,
634
+ uuid: parent.uuid,
635
+ display_id: parent.displayId,
636
+ title: parent.title,
637
+ status_code: parent.statusCode,
638
+ modified_at: parent.modifiedAt
639
+ });
640
+ }
641
+ }
642
+ return { transport_id, features: [...features.values()] };
643
+ });
644
+
645
+ // ------------------------------------------------------------ event situations
646
+
647
+ register('calm_close_event_situation', 'Close a Cloud ALM event situation',
648
+ 'Close (confirm) one Cloud ALM event situation (alert) by id or external id and, unless notify_ticketing is false, close its mapped tickets; requires confirmation exactly "CLOSE CALM EVENT SITUATION <event_situation_id>" or "CLOSE CALM EVENT SITUATION BY EXTERNAL ID <external_id>".',
649
+ z.object({
650
+ event_situation_id: uuid.optional(),
651
+ external_id: z.string().regex(/^[A-Za-z0-9._:-]{1,128}$/).optional(),
652
+ notify_ticketing: z.boolean().default(true),
653
+ confirmation
654
+ }).refine(
655
+ args => [args.event_situation_id, args.external_id].filter(value => value !== undefined).length === 1,
656
+ 'provide exactly one of event_situation_id, external_id'
657
+ ),
658
+ OVERWRITE_ANNOTATIONS,
659
+ async ({ event_situation_id, external_id, notify_ticketing, confirmation: given }) => {
660
+ requireConfirmation(given, event_situation_id
661
+ ? `CLOSE CALM EVENT SITUATION ${event_situation_id}`
662
+ : `CLOSE CALM EVENT SITUATION BY EXTERNAL ID ${external_id}`);
663
+ const path = event_situation_id
664
+ ? `${CLOUD_ALM_PATHS.eventSituations}/${enc(event_situation_id)}`
665
+ : `${CLOUD_ALM_PATHS.eventSituations}/externalid/${enc(external_id)}`;
666
+ const { status } = await rest.request('PUT', path, {
667
+ json: { state: 'CLOSED' },
668
+ headers: { 'x-calm-notification': String(notify_ticketing) },
669
+ expect: [200, 202, 204],
670
+ responseType: 'none',
671
+ withStatus: true
672
+ });
673
+ return {
674
+ closed: true,
675
+ status,
676
+ ...(event_situation_id ? { event_situation_id } : { external_id }),
677
+ notify_ticketing
678
+ };
679
+ });
680
+
681
+ return registeredNames;
682
+ }