sap-ai-dev-toolkit 0.9.9912 → 0.9.9914
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/.github/agents/abap-developer.agent.md +1 -1
- package/.github/agents/sap-integration-architect.agent.md +38 -0
- package/.github/agents/sap-integration-developer.agent.md +31 -0
- package/.github/agents/sap-solution-architect.agent.md +1 -0
- package/.github/skills/abap-development/SKILL.md +1 -1
- package/.github/skills/cds-development/SKILL.md +1 -1
- package/.github/skills/integration-suite-delivery/SKILL.md +74 -0
- package/.github/skills/sap-integration-architect/SKILL.md +76 -0
- package/NOTICE +10 -0
- package/README.md +36 -8
- package/dist/checksums.txt +5 -5
- package/dist/vsp-darwin-arm64 +0 -0
- package/dist/vsp-darwin-x64 +0 -0
- package/dist/vsp-linux-arm64 +0 -0
- package/dist/vsp-linux-x64 +0 -0
- package/dist/vsp-win32-x64.exe +0 -0
- package/inventory.md +66 -2
- package/package.json +5 -2
- package/patches/vsp-bas-proxy-auth.patch +88 -7
- package/scripts/run-live-integration.mjs +1079 -0
- package/src/apim-bundle.mjs +381 -0
- package/src/apim-tools.mjs +641 -0
- package/src/cpi-tools.mjs +1042 -0
- package/src/engineering-tools.mjs +105 -0
- package/src/iflow-bundle.mjs +282 -0
- package/src/integration-common.mjs +232 -0
- package/src/integration-config.mjs +167 -0
- package/src/integration-credentials.mjs +65 -0
- package/src/integration-http.mjs +266 -0
- package/src/integration-server.mjs +125 -0
- package/src/integration-templates/https-request-reply-http.iflw +391 -0
- package/src/integration-tools.mjs +27 -0
- package/src/integration-verify.mjs +118 -0
- package/src/launcher.mjs +14 -2
- package/src/mcp-config.mjs +18 -3
- package/src/setup.mjs +189 -11
- package/tools.md +81 -3
|
@@ -0,0 +1,1042 @@
|
|
|
1
|
+
// MCP tools for SAP Cloud Integration (CPI) over the /api/v1 OData API.
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import {
|
|
4
|
+
CREATE_ANNOTATIONS,
|
|
5
|
+
ERROR_TEXT_CAP,
|
|
6
|
+
OVERWRITE_ANNOTATIONS,
|
|
7
|
+
READ_ONLY_ANNOTATIONS,
|
|
8
|
+
REDACTED,
|
|
9
|
+
SINGLE_FILE_CAP,
|
|
10
|
+
FILE_TEXT_CAP,
|
|
11
|
+
bundleFiles,
|
|
12
|
+
capText,
|
|
13
|
+
confirmation,
|
|
14
|
+
entity,
|
|
15
|
+
fileEntry,
|
|
16
|
+
isoDate,
|
|
17
|
+
isoToOdataDateTime,
|
|
18
|
+
key,
|
|
19
|
+
list,
|
|
20
|
+
pick,
|
|
21
|
+
redactSecrets,
|
|
22
|
+
registerODataReader,
|
|
23
|
+
requireConfirmation,
|
|
24
|
+
secretValue,
|
|
25
|
+
sleep,
|
|
26
|
+
toolError,
|
|
27
|
+
top as topSchema
|
|
28
|
+
} from './integration-common.mjs';
|
|
29
|
+
import { IntegrationError, odataDate, odataKey } from './integration-http.mjs';
|
|
30
|
+
import {
|
|
31
|
+
MANIFEST_PATH,
|
|
32
|
+
PARAMETERS_PROP,
|
|
33
|
+
PARAMETERS_PROPDEF,
|
|
34
|
+
buildIflowBundle,
|
|
35
|
+
bumpPatchVersion,
|
|
36
|
+
mergePropdef,
|
|
37
|
+
parseProperties,
|
|
38
|
+
propertiesText,
|
|
39
|
+
readBundle,
|
|
40
|
+
replaceManifestVersion,
|
|
41
|
+
strFromU8,
|
|
42
|
+
strToU8,
|
|
43
|
+
validateParameters,
|
|
44
|
+
writeBundle
|
|
45
|
+
} from './iflow-bundle.mjs';
|
|
46
|
+
|
|
47
|
+
const CPI_BASE = '/api/v1';
|
|
48
|
+
const MAX_UPDATE_FILE_BYTES = 256 * 1024;
|
|
49
|
+
const MESSAGE_STATUSES = ['COMPLETED', 'PROCESSING', 'RETRY', 'ESCALATED', 'FAILED', 'CANCELLED', 'DISCARDED', 'ABANDONED'];
|
|
50
|
+
|
|
51
|
+
const packageId = z.string().regex(/^[A-Za-z][A-Za-z0-9_]{0,59}$/);
|
|
52
|
+
const iflowId = z.string().regex(/^[A-Za-z][A-Za-z0-9_.-]{0,99}$/);
|
|
53
|
+
// CPI's own rule (verified live): start with a letter or underscore; letters, digits, space, '.', '-', '_';
|
|
54
|
+
// no trailing period. Non-ASCII letters are rejected by the tenant with HTTP 400.
|
|
55
|
+
const iflowName = z.string().max(100)
|
|
56
|
+
.regex(/^[A-Za-z_][A-Za-z0-9_ .-]*$/, 'must start with a letter or underscore and contain only ASCII letters, digits, space, period, hyphen or underscore')
|
|
57
|
+
.refine(value => !value.endsWith('.'), 'must not end with a period');
|
|
58
|
+
const parameterMap = z.record(z.string().regex(/^[A-Za-z0-9_. -]{1,100}$/), z.string().max(4096));
|
|
59
|
+
|
|
60
|
+
const templateParameters = z.object({
|
|
61
|
+
sender_path: z.string().regex(/^\/[A-Za-z0-9_\-/.]{1,200}$/),
|
|
62
|
+
receiver_address: z.string().max(2048).regex(/^https?:\/\/[^\s]+$/, 'must be an http(s) URL'),
|
|
63
|
+
receiver_query: z.string().max(2048).optional(),
|
|
64
|
+
receiver_method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).default('GET'),
|
|
65
|
+
receiver_proxy_type: z.enum(['internet', 'on_premise']),
|
|
66
|
+
receiver_location_id: z.string().max(2048).optional(),
|
|
67
|
+
receiver_authentication: z.enum(['None', 'Basic', 'Principal Propagation', 'OAuth2 Client Credentials', 'Client Certificate']),
|
|
68
|
+
receiver_credential_name: z.string().min(1).max(200).optional(),
|
|
69
|
+
receiver_timeout_ms: z.number().int().min(1000).max(300000).default(60000)
|
|
70
|
+
}).strict();
|
|
71
|
+
|
|
72
|
+
const scriptSchema = z.object({ name: z.string().max(90), content: z.string() }).strict();
|
|
73
|
+
const artifactId = iflowId;
|
|
74
|
+
const artifactVersion = z.string().regex(/^(?:active|\d+\.\d+\.\d+)$/).default('active');
|
|
75
|
+
const messageGuid = z.string().regex(/^[A-Za-z0-9_-]{10,64}$/);
|
|
76
|
+
const waitSeconds = fallback => z.number().int().min(0).max(50).default(fallback);
|
|
77
|
+
// Discover (SAP standard) package ids are longer than design-time ids and may contain periods.
|
|
78
|
+
const sourcePackageId = z.string().regex(/^[A-Za-z][A-Za-z0-9_.]{0,199}$/);
|
|
79
|
+
const securityName = z.string().regex(/^[A-Za-z0-9 _.@-]{1,150}$/);
|
|
80
|
+
const partnerChars = /^[A-Za-z0-9\-._~@]+$/;
|
|
81
|
+
const numberText = z.union([z.number().int().min(0).max(99_999_999_999_999), z.string().regex(/^\d{1,14}$/)]);
|
|
82
|
+
|
|
83
|
+
// Design-time artifact kinds besides iFlows: entity set and Deploy<Kind>DesigntimeArtifact function import.
|
|
84
|
+
const ARTIFACT_TYPES = Object.freeze({
|
|
85
|
+
value_mapping: { set: 'ValueMappingDesigntimeArtifacts', deploy: 'DeployValueMappingDesigntimeArtifact' },
|
|
86
|
+
script_collection: { set: 'ScriptCollectionDesigntimeArtifacts', deploy: 'DeployScriptCollectionDesigntimeArtifact' },
|
|
87
|
+
message_mapping: { set: 'MessageMappingDesigntimeArtifacts', deploy: 'DeployMessageMappingDesigntimeArtifact' }
|
|
88
|
+
});
|
|
89
|
+
const artifactType = z.enum(Object.keys(ARTIFACT_TYPES));
|
|
90
|
+
const PACKAGE_ARTIFACT_SETS = Object.freeze({
|
|
91
|
+
iflows: 'IntegrationDesigntimeArtifacts',
|
|
92
|
+
value_mappings: 'ValueMappingDesigntimeArtifacts',
|
|
93
|
+
script_collections: 'ScriptCollectionDesigntimeArtifacts',
|
|
94
|
+
message_mappings: 'MessageMappingDesigntimeArtifacts'
|
|
95
|
+
});
|
|
96
|
+
const RUNTIME_STATUSES = ['STARTED', 'STARTING', 'STOPPING', 'ERROR'];
|
|
97
|
+
const KEYSTORES = ['system', 'backup_admin_system', 'KeyRenewal'];
|
|
98
|
+
const PARTNER_CONTENT_TYPES = ['xml', 'xsl', 'xsd', 'json', 'text', 'zip', 'gz', 'zlib', 'crt'];
|
|
99
|
+
const JMS_BROKER_FIELDS = ['Capacity', 'MaxCapacity', 'CapacityOk', 'CapacityWarning', 'CapacityError', 'QueueNumber', 'MaxQueueNumber',
|
|
100
|
+
'IsQueuesHigh', 'IsMessageSpoolHigh', 'IsTransactedSessionsHigh', 'IsConsumersHigh', 'IsProducersHigh'];
|
|
101
|
+
const MAX_VALUES_PER_PAIR = 500;
|
|
102
|
+
const MAX_LIST_ENTRIES = 500;
|
|
103
|
+
const MAX_RUN_STEPS = 200;
|
|
104
|
+
const STEP_ERROR_CAP = 1024;
|
|
105
|
+
const MAX_CERTIFICATE_BYTES = 10 * 1024;
|
|
106
|
+
|
|
107
|
+
const time = value => Date.parse(odataDate(value)) || 0;
|
|
108
|
+
const dates = (value, fields) => Object.fromEntries(fields.filter(field => value?.[field] !== undefined).map(field => [field, odataDate(value[field])]));
|
|
109
|
+
|
|
110
|
+
/** Tenant error text can echo request fields: strip the secret, including a whitespace-collapsed or truncated copy. */
|
|
111
|
+
function withoutSecret(error, secret) {
|
|
112
|
+
if (error?.name !== 'IntegrationError') return error;
|
|
113
|
+
let message = error.message;
|
|
114
|
+
for (const variant of new Set([secret, secret.replace(/\s+/g, ' ').trim()])) if (variant) message = message.replaceAll(variant, REDACTED);
|
|
115
|
+
for (let length = Math.min(secret.length, message.length) - 1; length >= 3; length -= 1) {
|
|
116
|
+
if (message.endsWith(secret.slice(0, length))) {
|
|
117
|
+
message = `${message.slice(0, -length)}${REDACTED}`;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return new IntegrationError(message);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Register cpi_* tools for one CPI client. */
|
|
125
|
+
export function registerCpiTools(register, client, { pollIntervalMs }) {
|
|
126
|
+
const cpi = (method, path, options) => client.request(method, `${CPI_BASE}${path}`, options);
|
|
127
|
+
const artifactPath = (id, version = 'active') => `/IntegrationDesigntimeArtifacts(Id=${key(id)},Version=${key(version)})`;
|
|
128
|
+
const runtimePath = id => `/IntegrationRuntimeArtifacts(${key(id)})`;
|
|
129
|
+
const designtimePath = (type, id, version = 'active') => `/${ARTIFACT_TYPES[type].set}(Id=${key(id)},Version=${key(version)})`;
|
|
130
|
+
const bundleBytes = path => cpi('GET', `${path}/$value`, { responseType: 'bytes', accept: 'application/zip, application/octet-stream' });
|
|
131
|
+
|
|
132
|
+
/** GET the entity; PUT bodyFor(existing) when it exists, else POST bodyFor(null) to the collection. */
|
|
133
|
+
async function createOrReplace(path, collection, bodyFor, { putExpect = [200, 201, 202, 204], postExpect = [200, 201, 202, 204] } = {}) {
|
|
134
|
+
const existing = await cpi('GET', path, { expect: [200, 404], withStatus: true });
|
|
135
|
+
if (existing.status === 200) {
|
|
136
|
+
await cpi('PUT', path, { json: bodyFor(existing.data ?? {}), expect: putExpect, responseType: 'text', accept: '*/*' });
|
|
137
|
+
return 'updated';
|
|
138
|
+
}
|
|
139
|
+
await cpi('POST', collection, { json: bodyFor(null), expect: postExpect, responseType: 'text', accept: '*/*' });
|
|
140
|
+
return 'created';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function runtimeError(id) {
|
|
144
|
+
const text = await cpi('GET', `${runtimePath(id)}/ErrorInformation/$value`, { responseType: 'text', accept: '*/*', expect: [200, 204] });
|
|
145
|
+
return text === null ? null : capText(readableRuntimeError(text), ERROR_TEXT_CAP).text;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ErrorInformation arrives as JSON ({ message: { messageId }, parameter: [text...] }); return its text.
|
|
149
|
+
function readableRuntimeError(text) {
|
|
150
|
+
try {
|
|
151
|
+
const parsed = JSON.parse(text);
|
|
152
|
+
const lines = Array.isArray(parsed?.parameter) ? parsed.parameter.filter(entry => typeof entry === 'string' && entry.trim()) : [];
|
|
153
|
+
if (lines.length) return [parsed.message?.messageId, ...lines].filter(Boolean).join('\n');
|
|
154
|
+
} catch {
|
|
155
|
+
// Plain-text error information: keep as is.
|
|
156
|
+
}
|
|
157
|
+
return text;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function runtimeSummary(artifact) {
|
|
161
|
+
return {
|
|
162
|
+
...pick(artifact, ['Id', 'Version', 'Name', 'Type', 'DeployedBy', 'Status']),
|
|
163
|
+
DeployedOn: odataDate(artifact.DeployedOn)
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
registerODataReader(register, {
|
|
168
|
+
name: 'cpi_odata_get',
|
|
169
|
+
title: 'Read CPI OData API',
|
|
170
|
+
service: 'Cloud Integration /api/v1',
|
|
171
|
+
request: cpi,
|
|
172
|
+
examples: "/JmsBrokers('Broker1') with $expand=QueueStates, /AccessPolicies, /KeystoreEntries with keystoreName=system, /MessageProcessingLogs('<guid>')/Runs"
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
register('cpi_list_packages', 'List CPI packages',
|
|
176
|
+
'List Cloud Integration design-time packages (Id, Name, ShortText, Version, Mode).',
|
|
177
|
+
{ top: z.number().int().min(1).max(200).default(100) },
|
|
178
|
+
READ_ONLY_ANNOTATIONS,
|
|
179
|
+
// The CPI tenant rejects $select on IntegrationPackages (HTTP 501); project client-side.
|
|
180
|
+
async ({ top }) => list(await cpi('GET', '/IntegrationPackages', {
|
|
181
|
+
query: { $top: top, $orderby: 'Name' }
|
|
182
|
+
})).map(entry => pick(entry, ['Id', 'Name', 'ShortText', 'Version', 'Mode'])));
|
|
183
|
+
|
|
184
|
+
register('cpi_list_iflows', 'List iFlows in a package',
|
|
185
|
+
'List the integration flows of one CPI package (Id, Name, Version, Description).',
|
|
186
|
+
{ package_id: packageId },
|
|
187
|
+
READ_ONLY_ANNOTATIONS,
|
|
188
|
+
async ({ package_id }) => list(await cpi('GET', `/IntegrationPackages(${key(package_id)})/IntegrationDesigntimeArtifacts`))
|
|
189
|
+
.map(artifact => pick(artifact, ['Id', 'Name', 'Version', 'Description'])));
|
|
190
|
+
|
|
191
|
+
register('cpi_get_iflow', 'Get iFlow',
|
|
192
|
+
'Read an iFlow: artifact metadata, externalized configuration and bundle files (text files capped at 48 KiB each). Pass file_path to return one file (up to 90 KiB).',
|
|
193
|
+
{
|
|
194
|
+
iflow_id: iflowId,
|
|
195
|
+
version: z.string().regex(/^(?:active|\d+\.\d+\.\d+)$/).default('active'),
|
|
196
|
+
file_path: z.string().min(1).max(300).optional()
|
|
197
|
+
},
|
|
198
|
+
READ_ONLY_ANNOTATIONS,
|
|
199
|
+
async ({ iflow_id, version, file_path }) => {
|
|
200
|
+
const path = artifactPath(iflow_id, version);
|
|
201
|
+
const artifact = entity(await cpi('GET', path));
|
|
202
|
+
delete artifact.ArtifactContent;
|
|
203
|
+
const bundle = readBundle(await cpi('GET', `${path}/$value`, { responseType: 'bytes', accept: 'application/zip, application/octet-stream' }));
|
|
204
|
+
if (file_path) {
|
|
205
|
+
const bytes = bundle.get(file_path);
|
|
206
|
+
if (!bytes) throw new IntegrationError('file_path not found in the iFlow bundle');
|
|
207
|
+
return { artifact, file: fileEntry(file_path, bytes, SINGLE_FILE_CAP) };
|
|
208
|
+
}
|
|
209
|
+
const configurations = list(await cpi('GET', `${path}/Configurations`))
|
|
210
|
+
.map(entry => pick(entry, ['ParameterKey', 'ParameterValue', 'DataType']));
|
|
211
|
+
const files = [...bundle.entries()]
|
|
212
|
+
.filter(([name]) => !name.endsWith('/'))
|
|
213
|
+
.map(([name, bytes]) => fileEntry(name, bytes, FILE_TEXT_CAP));
|
|
214
|
+
return { artifact, configurations, files };
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
register('cpi_get_iflow_runtime_status', 'Get iFlow runtime status',
|
|
218
|
+
'Return the deployed runtime state of an iFlow; includes error information when Status is ERROR.',
|
|
219
|
+
{ iflow_id: iflowId },
|
|
220
|
+
READ_ONLY_ANNOTATIONS,
|
|
221
|
+
async ({ iflow_id }) => {
|
|
222
|
+
const { status, data } = await cpi('GET', runtimePath(iflow_id), { expect: [200, 404], withStatus: true });
|
|
223
|
+
if (status === 404) return { deployed: false };
|
|
224
|
+
const result = { deployed: true, ...runtimeSummary(data) };
|
|
225
|
+
if (String(data?.Status).toUpperCase() === 'ERROR') result.error_information = await runtimeError(iflow_id);
|
|
226
|
+
return result;
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
register('cpi_list_service_endpoints', 'List CPI service endpoints',
|
|
230
|
+
'List deployed HTTP(S)/SOAP endpoint URLs; optionally only those of one iFlow. CPI registers an endpoint up to a few minutes after the iFlow reaches STARTED, so an empty list right after deployment means retry later.',
|
|
231
|
+
{ iflow_id: iflowId.optional() },
|
|
232
|
+
READ_ONLY_ANNOTATIONS,
|
|
233
|
+
async ({ iflow_id }) => {
|
|
234
|
+
let endpoints = list(await cpi('GET', '/ServiceEndpoints', { query: { $expand: 'EntryPoints' } }));
|
|
235
|
+
if (iflow_id) {
|
|
236
|
+
const byName = endpoints.filter(endpoint => endpoint.Name === iflow_id);
|
|
237
|
+
endpoints = byName.length ? byName : endpoints.filter(endpoint => String(endpoint.Id || '').includes(iflow_id));
|
|
238
|
+
}
|
|
239
|
+
return endpoints.map(endpoint => ({
|
|
240
|
+
name: endpoint.Name,
|
|
241
|
+
urls: list(endpoint.EntryPoints).map(entryPoint => entryPoint.Url).filter(Boolean)
|
|
242
|
+
}));
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
register('cpi_list_message_logs', 'List message processing logs',
|
|
246
|
+
'List CPI message processing logs, newest first, filtered by iFlow, status, time window (ISO-8601), correlation id, application message id or one custom header property (name and value, exact match).',
|
|
247
|
+
{
|
|
248
|
+
iflow_id: iflowId.optional(),
|
|
249
|
+
status: z.enum(MESSAGE_STATUSES).optional(),
|
|
250
|
+
from: isoDate.optional(),
|
|
251
|
+
to: isoDate.optional(),
|
|
252
|
+
correlation_id: z.string().min(1).max(200).optional(),
|
|
253
|
+
application_message_id: z.string().min(1).max(200).optional(),
|
|
254
|
+
custom_header: z.object({ name: z.string().min(1).max(200), value: z.string().max(2000) }).strict().optional(),
|
|
255
|
+
top: z.number().int().min(1).max(100).default(20)
|
|
256
|
+
},
|
|
257
|
+
READ_ONLY_ANNOTATIONS,
|
|
258
|
+
async ({ iflow_id, status, from, to, correlation_id, application_message_id, custom_header, top }) => {
|
|
259
|
+
const filter = [
|
|
260
|
+
iflow_id && `IntegrationArtifact/Id eq ${odataKey(iflow_id)}`,
|
|
261
|
+
status && `Status eq '${status}'`,
|
|
262
|
+
from && `LogStart gt datetime'${isoToOdataDateTime(from)}'`,
|
|
263
|
+
to && `LogEnd lt datetime'${isoToOdataDateTime(to)}'`,
|
|
264
|
+
correlation_id && `CorrelationId eq ${odataKey(correlation_id)}`,
|
|
265
|
+
application_message_id && `ApplicationMessageId eq ${odataKey(application_message_id)}`
|
|
266
|
+
].filter(Boolean).join(' and ');
|
|
267
|
+
const parsed = await cpi('GET', '/MessageProcessingLogs', {
|
|
268
|
+
query: {
|
|
269
|
+
$filter: filter || undefined,
|
|
270
|
+
$orderby: 'LogEnd desc',
|
|
271
|
+
$top: top,
|
|
272
|
+
$inlinecount: 'allpages',
|
|
273
|
+
filterCustomHeaderProperties: custom_header ? `${odataKey(custom_header.name)} eq ${odataKey(custom_header.value)}` : undefined
|
|
274
|
+
},
|
|
275
|
+
unwrap: false
|
|
276
|
+
});
|
|
277
|
+
const logs = list(parsed?.d ?? parsed).map(log => ({
|
|
278
|
+
...pick(log, ['MessageGuid', 'CorrelationId', 'IntegrationFlowName', 'Status', 'CustomStatus', 'Sender', 'Receiver']),
|
|
279
|
+
LogStart: odataDate(log.LogStart),
|
|
280
|
+
LogEnd: odataDate(log.LogEnd)
|
|
281
|
+
}));
|
|
282
|
+
const total = Number(parsed?.d?.__count);
|
|
283
|
+
return { total: Number.isFinite(total) ? total : null, returned: logs.length, logs };
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
register('cpi_get_message_log', 'Get message processing log',
|
|
287
|
+
'Read one message processing log with its error text and custom header properties. Payload attachments are never read.',
|
|
288
|
+
{ message_guid: messageGuid },
|
|
289
|
+
READ_ONLY_ANNOTATIONS,
|
|
290
|
+
async ({ message_guid }) => {
|
|
291
|
+
const path = `/MessageProcessingLogs(${key(message_guid)})`;
|
|
292
|
+
const log = entity(await cpi('GET', path));
|
|
293
|
+
for (const field of ['LogStart', 'LogEnd']) if (field in log) log[field] = odataDate(log[field]);
|
|
294
|
+
const errorText = await cpi('GET', `${path}/ErrorInformation/$value`, { responseType: 'text', accept: '*/*', expect: [200, 204, 404] });
|
|
295
|
+
const headers = list(await cpi('GET', `${path}/CustomHeaderProperties`)).map(entry => pick(entry, ['Name', 'Value']));
|
|
296
|
+
return { log, error_information: errorText ? capText(errorText, ERROR_TEXT_CAP).text : null, custom_header_properties: headers };
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
register('cpi_create_package', 'Create CPI package',
|
|
300
|
+
'Create a Cloud Integration package. Requires confirmation exactly equal to CREATE CPI PACKAGE <package_id>.',
|
|
301
|
+
{
|
|
302
|
+
package_id: packageId,
|
|
303
|
+
name: z.string().min(1).max(100),
|
|
304
|
+
short_text: z.string().min(1).max(255),
|
|
305
|
+
description: z.string().max(4096).optional(),
|
|
306
|
+
confirmation
|
|
307
|
+
},
|
|
308
|
+
CREATE_ANNOTATIONS,
|
|
309
|
+
async ({ package_id, name, short_text, description, confirmation: confirmed }) => {
|
|
310
|
+
requireConfirmation(confirmed, `CREATE CPI PACKAGE ${package_id}`);
|
|
311
|
+
await cpi('POST', '/IntegrationPackages', {
|
|
312
|
+
json: { Id: package_id, Name: name, ShortText: short_text, Description: description ?? '', Version: '1.0.0' },
|
|
313
|
+
expect: [201]
|
|
314
|
+
});
|
|
315
|
+
return { created: true, package_id };
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
register('cpi_create_iflow', 'Create iFlow',
|
|
319
|
+
'Create an iFlow in a package from the vetted https-request-reply-http template (HTTPS sender → Request Reply → HTTP receiver) or from caller-supplied BPMN (template=custom). Requires confirmation exactly equal to CREATE IFLOW <iflow_id>.',
|
|
320
|
+
z.object({
|
|
321
|
+
package_id: packageId,
|
|
322
|
+
iflow_id: iflowId,
|
|
323
|
+
name: iflowName,
|
|
324
|
+
description: z.string().max(4096).optional(),
|
|
325
|
+
template: z.enum(['https-request-reply-http', 'custom']),
|
|
326
|
+
template_parameters: templateParameters.optional(),
|
|
327
|
+
iflw_xml: z.string().optional(),
|
|
328
|
+
scripts: z.array(scriptSchema).max(10).optional(),
|
|
329
|
+
parameters: parameterMap.optional(),
|
|
330
|
+
confirmation
|
|
331
|
+
}).strict().superRefine((value, context) => {
|
|
332
|
+
if (value.template === 'custom' && !value.iflw_xml) {
|
|
333
|
+
context.addIssue({ code: 'custom', path: ['iflw_xml'], message: 'iflw_xml is required when template is custom' });
|
|
334
|
+
}
|
|
335
|
+
if (value.template !== 'custom' && !value.template_parameters) {
|
|
336
|
+
context.addIssue({ code: 'custom', path: ['template_parameters'], message: 'template_parameters are required for template iFlows' });
|
|
337
|
+
}
|
|
338
|
+
const auth = value.template_parameters?.receiver_authentication;
|
|
339
|
+
if ((auth === 'Basic' || auth === 'OAuth2 Client Credentials') && !value.template_parameters?.receiver_credential_name) {
|
|
340
|
+
context.addIssue({ code: 'custom', path: ['template_parameters', 'receiver_credential_name'], message: `receiver_credential_name is required for ${auth}` });
|
|
341
|
+
}
|
|
342
|
+
}),
|
|
343
|
+
CREATE_ANNOTATIONS,
|
|
344
|
+
async args => {
|
|
345
|
+
requireConfirmation(args.confirmation, `CREATE IFLOW ${args.iflow_id}`);
|
|
346
|
+
const bundle = buildIflowBundle({
|
|
347
|
+
id: args.iflow_id,
|
|
348
|
+
name: args.name,
|
|
349
|
+
description: args.description ?? '',
|
|
350
|
+
template: args.template,
|
|
351
|
+
templateParameters: args.template_parameters,
|
|
352
|
+
iflwXml: args.iflw_xml,
|
|
353
|
+
scripts: args.scripts ?? [],
|
|
354
|
+
parameters: args.parameters ?? {}
|
|
355
|
+
});
|
|
356
|
+
const existing = await cpi('GET', artifactPath(args.iflow_id), { expect: [200, 404], withStatus: true });
|
|
357
|
+
if (existing.status === 200) throw new IntegrationError(`iFlow ${args.iflow_id} already exists; use cpi_update_iflow`);
|
|
358
|
+
await cpi('POST', '/IntegrationDesigntimeArtifacts', {
|
|
359
|
+
json: {
|
|
360
|
+
Name: args.name,
|
|
361
|
+
Id: args.iflow_id,
|
|
362
|
+
PackageId: args.package_id,
|
|
363
|
+
Description: args.description ?? '',
|
|
364
|
+
ArtifactContent: Buffer.from(bundle).toString('base64')
|
|
365
|
+
},
|
|
366
|
+
expect: [201]
|
|
367
|
+
});
|
|
368
|
+
return { created: true, iflow_id: args.iflow_id, package_id: args.package_id, version: '1.0.0', files: [...readBundle(bundle).keys()] };
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
register('cpi_update_iflow', 'Update iFlow',
|
|
372
|
+
'Replace or add files under src/main/resources in an iFlow bundle, merge externalized parameters, and bump the patch version. Fails without writing when expected_version differs from the current version. Never deletes bundle files. Requires confirmation exactly equal to UPDATE IFLOW <iflow_id>.',
|
|
373
|
+
{
|
|
374
|
+
iflow_id: iflowId,
|
|
375
|
+
expected_version: z.string().min(1).max(40),
|
|
376
|
+
files: z.array(z.object({
|
|
377
|
+
path: z.string().regex(/^src\/main\/resources\/[A-Za-z0-9_\-/.]{1,200}$/).refine(value => !value.includes('..'), 'must not contain ..'),
|
|
378
|
+
content: z.string().refine(value => Buffer.byteLength(value, 'utf8') <= MAX_UPDATE_FILE_BYTES, 'must be at most 256 KiB')
|
|
379
|
+
}).strict()).min(1).max(20),
|
|
380
|
+
parameters: parameterMap.optional(),
|
|
381
|
+
name: iflowName.optional(),
|
|
382
|
+
confirmation
|
|
383
|
+
},
|
|
384
|
+
OVERWRITE_ANNOTATIONS,
|
|
385
|
+
async ({ iflow_id, expected_version, files, parameters, name, confirmation: confirmed }) => {
|
|
386
|
+
requireConfirmation(confirmed, `UPDATE IFLOW ${iflow_id}`);
|
|
387
|
+
if (parameters) validateParameters(parameters);
|
|
388
|
+
const path = artifactPath(iflow_id);
|
|
389
|
+
const artifact = await cpi('GET', path);
|
|
390
|
+
const current = artifact?.Version;
|
|
391
|
+
if (current !== expected_version) {
|
|
392
|
+
throw new IntegrationError(`iFlow ${iflow_id} is at version ${current}, not ${expected_version}; re-read with cpi_get_iflow`);
|
|
393
|
+
}
|
|
394
|
+
const newVersion = bumpPatchVersion(current);
|
|
395
|
+
const bundle = readBundle(await cpi('GET', `${path}/$value`, { responseType: 'bytes', accept: 'application/zip, application/octet-stream' }));
|
|
396
|
+
const changed = new Set();
|
|
397
|
+
for (const file of files) {
|
|
398
|
+
bundle.set(file.path, strToU8(file.content));
|
|
399
|
+
changed.add(file.path);
|
|
400
|
+
}
|
|
401
|
+
if (parameters && Object.keys(parameters).length) {
|
|
402
|
+
const existing = bundle.has(PARAMETERS_PROP) ? parseProperties(strFromU8(bundle.get(PARAMETERS_PROP))) : {};
|
|
403
|
+
const merged = { ...existing, ...parameters };
|
|
404
|
+
validateParameters(merged);
|
|
405
|
+
bundle.set(PARAMETERS_PROP, strToU8(propertiesText(merged)));
|
|
406
|
+
const propdef = bundle.has(PARAMETERS_PROPDEF) ? strFromU8(bundle.get(PARAMETERS_PROPDEF)) : '';
|
|
407
|
+
const updatedPropdef = mergePropdef(propdef, Object.keys(merged));
|
|
408
|
+
if (updatedPropdef !== propdef) {
|
|
409
|
+
bundle.set(PARAMETERS_PROPDEF, strToU8(updatedPropdef));
|
|
410
|
+
changed.add(PARAMETERS_PROPDEF);
|
|
411
|
+
}
|
|
412
|
+
changed.add(PARAMETERS_PROP);
|
|
413
|
+
}
|
|
414
|
+
if (!bundle.has(MANIFEST_PATH)) throw new IntegrationError(`iFlow ${iflow_id} bundle has no ${MANIFEST_PATH}`);
|
|
415
|
+
bundle.set(MANIFEST_PATH, strToU8(replaceManifestVersion(strFromU8(bundle.get(MANIFEST_PATH)), newVersion)));
|
|
416
|
+
changed.add(MANIFEST_PATH);
|
|
417
|
+
await cpi('PUT', path, {
|
|
418
|
+
json: { Name: name ?? artifact.Name, ArtifactContent: Buffer.from(writeBundle(bundle)).toString('base64') },
|
|
419
|
+
expect: [200, 202, 204]
|
|
420
|
+
});
|
|
421
|
+
return { updated: true, previous_version: current, new_version: newVersion, changed_files: [...changed] };
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
register('cpi_update_iflow_configuration', 'Configure iFlow parameters',
|
|
425
|
+
'Set externalized iFlow configuration parameters one at a time; stops at the first failure and reports which keys were updated. Requires confirmation exactly equal to CONFIGURE IFLOW <iflow_id>.',
|
|
426
|
+
{
|
|
427
|
+
iflow_id: iflowId,
|
|
428
|
+
parameters: z.array(z.object({
|
|
429
|
+
key: z.string().min(1).max(200),
|
|
430
|
+
value: z.string().max(4096),
|
|
431
|
+
data_type: z.string().min(1).max(40).default('xsd:string')
|
|
432
|
+
}).strict()).min(1).max(50),
|
|
433
|
+
confirmation
|
|
434
|
+
},
|
|
435
|
+
OVERWRITE_ANNOTATIONS,
|
|
436
|
+
async ({ iflow_id, parameters, confirmation: confirmed }) => {
|
|
437
|
+
requireConfirmation(confirmed, `CONFIGURE IFLOW ${iflow_id}`);
|
|
438
|
+
const updated = [];
|
|
439
|
+
for (const parameter of parameters) {
|
|
440
|
+
try {
|
|
441
|
+
await cpi('PUT', `${artifactPath(iflow_id)}/$links/Configurations(${key(parameter.key)})`, {
|
|
442
|
+
json: { ParameterValue: parameter.value, DataType: parameter.data_type },
|
|
443
|
+
expect: [200, 202, 204]
|
|
444
|
+
});
|
|
445
|
+
updated.push(parameter.key);
|
|
446
|
+
} catch (error) {
|
|
447
|
+
return { updated, failed: { key: parameter.key, error: toolError(error).content[0].text } };
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return { updated, failed: null };
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// MCP clients commonly abort a tool call after 60 s, so the wait stays below that;
|
|
454
|
+
// a long STARTING phase is reported as timed_out and continued with cpi_get_iflow_runtime_status.
|
|
455
|
+
register('cpi_deploy_iflow', 'Deploy iFlow',
|
|
456
|
+
'Deploy the active iFlow version and wait (up to wait_seconds, max 50) for build and runtime status; returns runtime error text when it fails to start. When timed_out is true, poll cpi_get_iflow_runtime_status. Requires confirmation exactly equal to DEPLOY IFLOW <iflow_id>.',
|
|
457
|
+
{ iflow_id: iflowId, wait_seconds: z.number().int().min(0).max(50).default(45), confirmation },
|
|
458
|
+
CREATE_ANNOTATIONS,
|
|
459
|
+
async ({ iflow_id, wait_seconds, confirmation: confirmed }) => {
|
|
460
|
+
requireConfirmation(confirmed, `DEPLOY IFLOW ${iflow_id}`);
|
|
461
|
+
const deadline = Date.now() + wait_seconds * 1000;
|
|
462
|
+
const taskId = String(await cpi('POST', '/DeployIntegrationDesigntimeArtifact', {
|
|
463
|
+
query: { Id: odataKey(iflow_id), Version: "'active'" },
|
|
464
|
+
responseType: 'text',
|
|
465
|
+
accept: '*/*',
|
|
466
|
+
expect: [200, 202]
|
|
467
|
+
}) ?? '').trim().replace(/^"|"$/g, '');
|
|
468
|
+
if (!taskId) throw new IntegrationError(`CPI deploy of ${iflow_id} returned no task id`);
|
|
469
|
+
|
|
470
|
+
let buildStatus;
|
|
471
|
+
for (;;) {
|
|
472
|
+
const build = await cpi('GET', `/BuildAndDeployStatus(TaskId=${key(taskId)})`);
|
|
473
|
+
buildStatus = String(build?.Status ?? '').toUpperCase();
|
|
474
|
+
if (buildStatus !== 'DEPLOYING' || Date.now() >= deadline) break;
|
|
475
|
+
await sleep(pollIntervalMs);
|
|
476
|
+
}
|
|
477
|
+
const result = { task_id: taskId, build_status: buildStatus, runtime_status: null, runtime_version: null, error_information: null, timed_out: false };
|
|
478
|
+
const readRuntime = async () => {
|
|
479
|
+
const runtime = await cpi('GET', runtimePath(iflow_id), { expect: [200, 404], withStatus: true });
|
|
480
|
+
result.runtime_status = runtime.status === 404 ? null : String(runtime.data?.Status ?? '').toUpperCase();
|
|
481
|
+
result.runtime_version = runtime.status === 404 ? null : runtime.data?.Version ?? null;
|
|
482
|
+
};
|
|
483
|
+
if (buildStatus === 'DEPLOYING') {
|
|
484
|
+
return { ...result, timed_out: true, note: 'Build still running; poll cpi_get_iflow_runtime_status.' };
|
|
485
|
+
}
|
|
486
|
+
if (buildStatus !== 'SUCCESS') {
|
|
487
|
+
// CPI occasionally reports a transient build FAIL for a deploy that does start; report the runtime truth too.
|
|
488
|
+
await readRuntime();
|
|
489
|
+
if (result.runtime_status === 'ERROR') result.error_information = await runtimeError(iflow_id);
|
|
490
|
+
result.note = `CPI reported build status ${buildStatus || 'unknown'}; runtime_status/runtime_version show the iFlow currently deployed. Check cpi_get_iflow_runtime_status before redeploying.`;
|
|
491
|
+
return result;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
for (;;) {
|
|
495
|
+
await readRuntime();
|
|
496
|
+
const pending = result.runtime_status === null || result.runtime_status === 'STARTING';
|
|
497
|
+
if (!pending) break;
|
|
498
|
+
if (Date.now() >= deadline) {
|
|
499
|
+
result.timed_out = true;
|
|
500
|
+
result.note = 'Runtime still starting; poll cpi_get_iflow_runtime_status.';
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
await sleep(pollIntervalMs);
|
|
504
|
+
}
|
|
505
|
+
if (result.runtime_status === 'ERROR') result.error_information = await runtimeError(iflow_id);
|
|
506
|
+
return result;
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
register('cpi_list_runtime_artifacts', 'List deployed CPI artifacts',
|
|
510
|
+
'List deployed (runtime) Cloud Integration artifacts of every type (iFlows, value mappings, script collections, message mappings, …) with status and deployment time; optionally only one status.',
|
|
511
|
+
{ status: z.enum(RUNTIME_STATUSES).optional(), top: topSchema(500, 200) },
|
|
512
|
+
READ_ONLY_ANNOTATIONS,
|
|
513
|
+
async ({ status, top }) => list(await cpi('GET', '/IntegrationRuntimeArtifacts', {
|
|
514
|
+
query: { $filter: status ? `Status eq '${status}'` : undefined, $top: top }
|
|
515
|
+
})).map(runtimeSummary));
|
|
516
|
+
|
|
517
|
+
register('cpi_undeploy_artifact', 'Undeploy CPI artifact',
|
|
518
|
+
'Undeploy a runtime artifact of any type and wait (up to wait_seconds, max 50) until it is gone; the design-time artifact is kept. When timed_out is true, poll cpi_list_runtime_artifacts. Requires confirmation exactly equal to UNDEPLOY ARTIFACT <artifact_id>.',
|
|
519
|
+
{ artifact_id: artifactId, wait_seconds: waitSeconds(30), confirmation },
|
|
520
|
+
OVERWRITE_ANNOTATIONS,
|
|
521
|
+
async ({ artifact_id, wait_seconds, confirmation: confirmed }) => {
|
|
522
|
+
requireConfirmation(confirmed, `UNDEPLOY ARTIFACT ${artifact_id}`);
|
|
523
|
+
const deadline = Date.now() + wait_seconds * 1000;
|
|
524
|
+
const current = await cpi('GET', runtimePath(artifact_id), { expect: [200, 404], withStatus: true });
|
|
525
|
+
if (current.status === 404) throw new IntegrationError(`Artifact ${artifact_id} is not deployed`);
|
|
526
|
+
await cpi('DELETE', runtimePath(artifact_id), { expect: [200, 202, 204], responseType: 'text', accept: '*/*' });
|
|
527
|
+
for (;;) {
|
|
528
|
+
const runtime = await cpi('GET', runtimePath(artifact_id), { expect: [200, 404], withStatus: true });
|
|
529
|
+
if (runtime.status === 404) return { undeployed: true, artifact_id, previous_version: current.data?.Version ?? null };
|
|
530
|
+
if (Date.now() >= deadline) {
|
|
531
|
+
return {
|
|
532
|
+
undeployed: false,
|
|
533
|
+
timed_out: true,
|
|
534
|
+
artifact_id,
|
|
535
|
+
runtime_status: String(runtime.data?.Status ?? '').toUpperCase() || null,
|
|
536
|
+
note: 'Undeployment still running; poll cpi_list_runtime_artifacts until the artifact disappears.'
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
await sleep(pollIntervalMs);
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
register('cpi_list_package_artifacts', 'List artifacts in a package',
|
|
544
|
+
'List the iFlows, value mappings, script collections and message mappings of one CPI package (Id, Name, Version, Description), grouped by type.',
|
|
545
|
+
{ package_id: packageId },
|
|
546
|
+
READ_ONLY_ANNOTATIONS,
|
|
547
|
+
async ({ package_id }) => {
|
|
548
|
+
const result = {};
|
|
549
|
+
for (const [group, set] of Object.entries(PACKAGE_ARTIFACT_SETS)) {
|
|
550
|
+
result[group] = list(await cpi('GET', `/IntegrationPackages(${key(package_id)})/${set}`))
|
|
551
|
+
.map(artifact => pick(artifact, ['Id', 'Name', 'Version', 'Description']));
|
|
552
|
+
}
|
|
553
|
+
return result;
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
register('cpi_get_artifact', 'Get CPI artifact',
|
|
557
|
+
'Read a value mapping, script collection or message mapping: metadata and bundle files (text files capped at 48 KiB each). Pass file_path to return one file (up to 90 KiB).',
|
|
558
|
+
{ type: artifactType, artifact_id: artifactId, version: artifactVersion, file_path: z.string().min(1).max(300).optional() },
|
|
559
|
+
READ_ONLY_ANNOTATIONS,
|
|
560
|
+
async ({ type, artifact_id, version, file_path }) => {
|
|
561
|
+
const path = designtimePath(type, artifact_id, version);
|
|
562
|
+
const artifact = entity(await cpi('GET', path));
|
|
563
|
+
delete artifact.ArtifactContent;
|
|
564
|
+
return { artifact, ...bundleFiles(readBundle(await bundleBytes(path)), file_path) };
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
register('cpi_get_value_mappings', 'Get value mapping values',
|
|
568
|
+
`Read the agency/identifier pairs of a value mapping with their value pairs (up to ${MAX_VALUES_PER_PAIR} per agency pair) and default values.`,
|
|
569
|
+
{ artifact_id: artifactId, version: artifactVersion },
|
|
570
|
+
READ_ONLY_ANNOTATIONS,
|
|
571
|
+
async ({ artifact_id, version }) => {
|
|
572
|
+
const path = designtimePath('value_mapping', artifact_id, version);
|
|
573
|
+
const valMap = entry => ({ Id: entry.Id, SrcValue: entry.Value?.SrcValue ?? null, TgtValue: entry.Value?.TgtValue ?? null });
|
|
574
|
+
const pairs = [];
|
|
575
|
+
for (const schema of list(await cpi('GET', `${path}/ValMapSchema`))) {
|
|
576
|
+
const pairPath = `${path}/ValMapSchema(SrcAgency=${key(schema.SrcAgency)},SrcId=${key(schema.SrcId)},TgtAgency=${key(schema.TgtAgency)},TgtId=${key(schema.TgtId)})`;
|
|
577
|
+
const values = list(await cpi('GET', `${pairPath}/ValMaps`)).map(valMap);
|
|
578
|
+
const defaults = list(await cpi('GET', `${pairPath}/DefaultValMaps`)).map(valMap);
|
|
579
|
+
pairs.push({
|
|
580
|
+
...pick(schema, ['SrcAgency', 'SrcId', 'TgtAgency', 'TgtId', 'State']),
|
|
581
|
+
value_count: values.length,
|
|
582
|
+
values: values.slice(0, MAX_VALUES_PER_PAIR),
|
|
583
|
+
...(values.length > MAX_VALUES_PER_PAIR ? { values_truncated: true } : {}),
|
|
584
|
+
default_values: defaults
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
return { artifact_id, version, agency_pairs: pairs };
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
const agencyField = z.string().min(1).max(200);
|
|
591
|
+
register('cpi_upsert_value_mappings', 'Upsert value mapping values',
|
|
592
|
+
'Add or change value pairs of the active value mapping version, one at a time; stops at the first failure and reports how many were written. Agency pairs that do not exist yet are created. Takes effect after cpi_deploy_artifact (type value_mapping). Requires confirmation exactly equal to UPDATE VALUE MAPPING <artifact_id>.',
|
|
593
|
+
{
|
|
594
|
+
artifact_id: artifactId,
|
|
595
|
+
pairs: z.array(z.object({
|
|
596
|
+
src_agency: agencyField,
|
|
597
|
+
src_identifier: agencyField,
|
|
598
|
+
tgt_agency: agencyField,
|
|
599
|
+
tgt_identifier: agencyField,
|
|
600
|
+
src_value: z.string().min(1).max(2000),
|
|
601
|
+
tgt_value: z.string().min(1).max(2000)
|
|
602
|
+
}).strict()).min(1).max(100),
|
|
603
|
+
confirmation
|
|
604
|
+
},
|
|
605
|
+
OVERWRITE_ANNOTATIONS,
|
|
606
|
+
async ({ artifact_id, pairs, confirmation: confirmed }) => {
|
|
607
|
+
requireConfirmation(confirmed, `UPDATE VALUE MAPPING ${artifact_id}`);
|
|
608
|
+
let upserted = 0;
|
|
609
|
+
for (const [index, pair] of pairs.entries()) {
|
|
610
|
+
try {
|
|
611
|
+
await cpi('POST', '/UpsertValMaps', {
|
|
612
|
+
query: {
|
|
613
|
+
Id: odataKey(artifact_id),
|
|
614
|
+
Version: "'active'",
|
|
615
|
+
SrcAgency: odataKey(pair.src_agency),
|
|
616
|
+
SrcId: odataKey(pair.src_identifier),
|
|
617
|
+
TgtAgency: odataKey(pair.tgt_agency),
|
|
618
|
+
TgtId: odataKey(pair.tgt_identifier),
|
|
619
|
+
SrcValue: odataKey(pair.src_value),
|
|
620
|
+
TgtValue: odataKey(pair.tgt_value),
|
|
621
|
+
IsConfigured: 'true'
|
|
622
|
+
},
|
|
623
|
+
responseType: 'text',
|
|
624
|
+
accept: '*/*',
|
|
625
|
+
expect: [200, 202, 204]
|
|
626
|
+
});
|
|
627
|
+
upserted += 1;
|
|
628
|
+
} catch (error) {
|
|
629
|
+
return { upserted, failed: { index, error: toolError(error).content[0].text } };
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return { upserted, failed: null, note: 'Deploy with cpi_deploy_artifact (type value_mapping) for the change to take effect.' };
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
register('cpi_update_script_collection', 'Update script collection',
|
|
636
|
+
'Replace or add scripts (src/main/resources/script/<name>) in the active version of a script collection; other files are kept and the version is not changed. Fails without writing when expected_version is given and differs. Takes effect after cpi_deploy_artifact (type script_collection). Requires confirmation exactly equal to UPDATE SCRIPT COLLECTION <artifact_id>.',
|
|
637
|
+
{
|
|
638
|
+
artifact_id: artifactId,
|
|
639
|
+
expected_version: z.string().min(1).max(40).optional(),
|
|
640
|
+
files: z.array(z.object({
|
|
641
|
+
name: z.string().regex(/^[A-Za-z0-9_.-]{1,80}\.(?:groovy|gsh|js)$/, 'must be a .groovy, .gsh or .js file name without a path'),
|
|
642
|
+
content: z.string().refine(value => Buffer.byteLength(value, 'utf8') <= MAX_UPDATE_FILE_BYTES, 'must be at most 256 KiB')
|
|
643
|
+
}).strict()).min(1).max(20),
|
|
644
|
+
confirmation
|
|
645
|
+
},
|
|
646
|
+
OVERWRITE_ANNOTATIONS,
|
|
647
|
+
async ({ artifact_id, expected_version, files, confirmation: confirmed }) => {
|
|
648
|
+
requireConfirmation(confirmed, `UPDATE SCRIPT COLLECTION ${artifact_id}`);
|
|
649
|
+
const path = designtimePath('script_collection', artifact_id);
|
|
650
|
+
const artifact = await cpi('GET', path);
|
|
651
|
+
if (expected_version !== undefined && artifact?.Version !== expected_version) {
|
|
652
|
+
throw new IntegrationError(`Script collection ${artifact_id} is at version ${artifact?.Version}, not ${expected_version}; re-read with cpi_get_artifact`);
|
|
653
|
+
}
|
|
654
|
+
const bundle = readBundle(await bundleBytes(path));
|
|
655
|
+
const changed = new Set();
|
|
656
|
+
for (const file of files) {
|
|
657
|
+
const filePath = `src/main/resources/script/${file.name}`;
|
|
658
|
+
bundle.set(filePath, strToU8(file.content));
|
|
659
|
+
changed.add(filePath);
|
|
660
|
+
}
|
|
661
|
+
await cpi('PUT', path, {
|
|
662
|
+
json: { Name: artifact?.Name, ArtifactContent: Buffer.from(writeBundle(bundle)).toString('base64') },
|
|
663
|
+
responseType: 'text',
|
|
664
|
+
accept: '*/*',
|
|
665
|
+
expect: [200, 202, 204]
|
|
666
|
+
});
|
|
667
|
+
return { updated: true, artifact_id, version: artifact?.Version ?? null, changed_files: [...changed] };
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
register('cpi_deploy_artifact', 'Deploy CPI artifact',
|
|
671
|
+
'Deploy the active version of a value mapping, script collection or message mapping and wait (up to wait_seconds, max 50) until the runtime artifact leaves STARTING; returns runtime error text when it fails. A redeploy can briefly report the previously deployed artifact as STARTED, so check runtime_version and deployed_on. When timed_out is true, poll cpi_list_runtime_artifacts. Requires confirmation exactly equal to DEPLOY ARTIFACT <artifact_id>.',
|
|
672
|
+
{ type: artifactType, artifact_id: artifactId, wait_seconds: waitSeconds(45), confirmation },
|
|
673
|
+
CREATE_ANNOTATIONS,
|
|
674
|
+
async ({ type, artifact_id, wait_seconds, confirmation: confirmed }) => {
|
|
675
|
+
requireConfirmation(confirmed, `DEPLOY ARTIFACT ${artifact_id}`);
|
|
676
|
+
const deadline = Date.now() + wait_seconds * 1000;
|
|
677
|
+
await cpi('POST', `/${ARTIFACT_TYPES[type].deploy}`, {
|
|
678
|
+
query: { Id: odataKey(artifact_id), Version: "'active'" },
|
|
679
|
+
responseType: 'text',
|
|
680
|
+
accept: '*/*',
|
|
681
|
+
expect: [200, 201, 202]
|
|
682
|
+
});
|
|
683
|
+
const result = { type, artifact_id, runtime_status: null, runtime_version: null, deployed_on: null, error_information: null, timed_out: false };
|
|
684
|
+
for (;;) {
|
|
685
|
+
const runtime = await cpi('GET', runtimePath(artifact_id), { expect: [200, 404], withStatus: true });
|
|
686
|
+
const deployed = runtime.status === 200;
|
|
687
|
+
result.runtime_status = deployed ? String(runtime.data?.Status ?? '').toUpperCase() : null;
|
|
688
|
+
result.runtime_version = deployed ? runtime.data?.Version ?? null : null;
|
|
689
|
+
result.deployed_on = deployed ? odataDate(runtime.data?.DeployedOn) ?? null : null;
|
|
690
|
+
if (deployed && result.runtime_status !== 'STARTING') break;
|
|
691
|
+
if (Date.now() >= deadline) {
|
|
692
|
+
result.timed_out = true;
|
|
693
|
+
result.note = 'Runtime artifact not started yet; poll cpi_list_runtime_artifacts.';
|
|
694
|
+
break;
|
|
695
|
+
}
|
|
696
|
+
await sleep(pollIntervalMs);
|
|
697
|
+
}
|
|
698
|
+
if (result.runtime_status === 'ERROR') result.error_information = await runtimeError(artifact_id);
|
|
699
|
+
return result;
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
register('cpi_update_package', 'Update CPI package',
|
|
703
|
+
'Change the name, short text, description or version of a CPI package; omitted fields keep their current values. Requires confirmation exactly equal to UPDATE CPI PACKAGE <package_id>.',
|
|
704
|
+
z.object({
|
|
705
|
+
package_id: packageId,
|
|
706
|
+
name: z.string().min(1).max(100).optional(),
|
|
707
|
+
short_text: z.string().min(1).max(255).optional(),
|
|
708
|
+
description: z.string().max(4096).optional(),
|
|
709
|
+
version: z.string().regex(/^\d+\.\d+\.\d+$/).optional(),
|
|
710
|
+
confirmation
|
|
711
|
+
}).strict().refine(value => ['name', 'short_text', 'description', 'version'].some(field => value[field] !== undefined),
|
|
712
|
+
'pass at least one of name, short_text, description, version'),
|
|
713
|
+
OVERWRITE_ANNOTATIONS,
|
|
714
|
+
async ({ package_id, name, short_text, description, version, confirmation: confirmed }) => {
|
|
715
|
+
requireConfirmation(confirmed, `UPDATE CPI PACKAGE ${package_id}`);
|
|
716
|
+
const path = `/IntegrationPackages(${key(package_id)})`;
|
|
717
|
+
const current = await cpi('GET', path);
|
|
718
|
+
const body = {
|
|
719
|
+
Name: name ?? current?.Name,
|
|
720
|
+
ShortText: short_text ?? current?.ShortText,
|
|
721
|
+
Description: description ?? current?.Description ?? '',
|
|
722
|
+
Version: version ?? current?.Version
|
|
723
|
+
};
|
|
724
|
+
await cpi('PUT', path, { json: body, responseType: 'text', accept: '*/*', expect: [200, 202, 204] });
|
|
725
|
+
return { updated: true, package_id, name: body.Name, short_text: body.ShortText, version: body.Version };
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
register('cpi_copy_package', 'Copy package from Discover',
|
|
729
|
+
'Copy a package from the Discover catalog (SAP standard content) into Design. CREATE_COPY creates a new package (suffix appends .<suffix> to the name and artifact ids); OVERWRITE_MERGE updates an existing copy and keeps its externalized configuration. Requires confirmation exactly equal to COPY CPI PACKAGE <package_id>.',
|
|
730
|
+
z.object({
|
|
731
|
+
package_id: sourcePackageId,
|
|
732
|
+
import_mode: z.enum(['CREATE_COPY', 'OVERWRITE_MERGE']).default('CREATE_COPY'),
|
|
733
|
+
suffix: z.string().regex(/^[A-Za-z0-9]{1,20}$/).optional(),
|
|
734
|
+
confirmation
|
|
735
|
+
}).strict().refine(value => !value.suffix || value.import_mode === 'CREATE_COPY', { path: ['suffix'], message: 'suffix is only allowed with import_mode CREATE_COPY' }),
|
|
736
|
+
OVERWRITE_ANNOTATIONS,
|
|
737
|
+
async ({ package_id, import_mode, suffix, confirmation: confirmed }) => {
|
|
738
|
+
requireConfirmation(confirmed, `COPY CPI PACKAGE ${package_id}`);
|
|
739
|
+
let copied;
|
|
740
|
+
try {
|
|
741
|
+
copied = await cpi('POST', '/CopyIntegrationPackage', {
|
|
742
|
+
query: { Id: odataKey(package_id), ImportMode: `'${import_mode}'`, Suffix: suffix ? `'${suffix}'` : undefined },
|
|
743
|
+
expect: [200, 201]
|
|
744
|
+
});
|
|
745
|
+
} catch (error) {
|
|
746
|
+
if (/\(HTTP 409\)/.test(error?.message)) {
|
|
747
|
+
throw new IntegrationError(`Package ${package_id} conflicts with an existing design-time package (HTTP 409); use import_mode OVERWRITE_MERGE or CREATE_COPY with a different suffix`);
|
|
748
|
+
}
|
|
749
|
+
throw error;
|
|
750
|
+
}
|
|
751
|
+
return { copied: true, import_mode, package: pick(copied ?? {}, ['Id', 'Name', 'Version']) };
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
// ---------------------------------------------------------------- security material
|
|
755
|
+
|
|
756
|
+
const deployment = descriptor => (descriptor ? { ...pick(descriptor, ['Type', 'Status', 'DeployedBy']), ...dates(descriptor, ['DeployedOn']) } : null);
|
|
757
|
+
register('cpi_list_security_material', 'List CPI security material',
|
|
758
|
+
'List user credentials, OAuth2 client credentials or keystore entries (keystore system, backup_admin_system or KeyRenewal; default system) with deployment status. Passwords, client secrets and keys are never returned.',
|
|
759
|
+
z.object({
|
|
760
|
+
kind: z.enum(['user_credentials', 'oauth2_client_credentials', 'keystore_entries']),
|
|
761
|
+
keystore: z.enum(KEYSTORES).optional()
|
|
762
|
+
}).strict().refine(value => !value.keystore || value.kind === 'keystore_entries', { path: ['keystore'], message: 'keystore is only allowed with kind keystore_entries' }),
|
|
763
|
+
READ_ONLY_ANNOTATIONS,
|
|
764
|
+
async ({ kind, keystore }) => {
|
|
765
|
+
if (kind === 'user_credentials') {
|
|
766
|
+
return redactSecrets(list(await cpi('GET', '/UserCredentials')).map(entry => ({
|
|
767
|
+
...pick(entry, ['Name', 'Kind', 'Description', 'User', 'CompanyId']),
|
|
768
|
+
deployment: deployment(entry.SecurityArtifactDescriptor)
|
|
769
|
+
})));
|
|
770
|
+
}
|
|
771
|
+
if (kind === 'oauth2_client_credentials') {
|
|
772
|
+
return redactSecrets(list(await cpi('GET', '/OAuth2ClientCredentials')).map(entry => ({
|
|
773
|
+
...pick(entry, ['Name', 'Description', 'TokenServiceUrl', 'ClientId', 'ClientAuthentication', 'Scope', 'ScopeContentType', 'Resource', 'Audience']),
|
|
774
|
+
deployment: deployment(entry.SecurityArtifactDescriptor)
|
|
775
|
+
})));
|
|
776
|
+
}
|
|
777
|
+
return redactSecrets(list(await cpi('GET', '/KeystoreEntries', { query: { keystoreName: keystore ?? 'system' } })).map(entry => ({
|
|
778
|
+
...pick(entry, ['Alias', 'Hexalias', 'Type', 'Owner', 'KeyType', 'SubjectDN', 'IssuerDN', 'Status']),
|
|
779
|
+
...dates(entry, ['ValidNotBefore', 'ValidNotAfter'])
|
|
780
|
+
})));
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
register('cpi_set_user_credential', 'Set CPI user credential',
|
|
784
|
+
'Create or replace a User Credentials security artifact (kind default, successfactors with company_id, or openconnectors). The password is write-only and never returned. Requires confirmation exactly equal to SET USER CREDENTIAL <name>.',
|
|
785
|
+
z.object({
|
|
786
|
+
name: securityName,
|
|
787
|
+
kind: z.enum(['default', 'successfactors', 'openconnectors']).default('default'),
|
|
788
|
+
user: z.string().min(1).max(255),
|
|
789
|
+
password: secretValue,
|
|
790
|
+
company_id: z.string().min(1).max(100).optional(),
|
|
791
|
+
description: z.string().max(1024).optional(),
|
|
792
|
+
confirmation
|
|
793
|
+
}).strict().refine(value => value.kind !== 'successfactors' || value.company_id, { path: ['company_id'], message: 'company_id is required for kind successfactors' }),
|
|
794
|
+
OVERWRITE_ANNOTATIONS,
|
|
795
|
+
async ({ name, kind, user, password, company_id, description, confirmation: confirmed }) => {
|
|
796
|
+
requireConfirmation(confirmed, `SET USER CREDENTIAL ${name}`);
|
|
797
|
+
try {
|
|
798
|
+
const outcome = await createOrReplace(`/UserCredentials(${key(name)})`, '/UserCredentials', existing => ({
|
|
799
|
+
Name: name,
|
|
800
|
+
Kind: kind,
|
|
801
|
+
Description: description ?? existing?.Description ?? '',
|
|
802
|
+
User: user,
|
|
803
|
+
Password: password,
|
|
804
|
+
CompanyId: company_id ?? ''
|
|
805
|
+
}));
|
|
806
|
+
return { [outcome]: true, name };
|
|
807
|
+
} catch (error) {
|
|
808
|
+
throw withoutSecret(error, password);
|
|
809
|
+
}
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
register('cpi_set_oauth2_client_credential', 'Set CPI OAuth2 client credential',
|
|
813
|
+
'Create or replace an OAuth2 Client Credentials security artifact. The client secret is write-only and never returned. Requires confirmation exactly equal to SET OAUTH2 CLIENT CREDENTIAL <name>.',
|
|
814
|
+
{
|
|
815
|
+
name: securityName,
|
|
816
|
+
token_service_url: z.string().max(2048).regex(/^https:\/\/\S+$/, 'must be an https URL'),
|
|
817
|
+
client_id: z.string().min(1).max(1024),
|
|
818
|
+
client_secret: secretValue,
|
|
819
|
+
client_authentication: z.enum(['body', 'header']).default('body'),
|
|
820
|
+
scope: z.string().max(2048).optional(),
|
|
821
|
+
scope_content_type: z.enum(['urlencoded', 'json']).default('urlencoded'),
|
|
822
|
+
resource: z.string().max(2048).optional(),
|
|
823
|
+
audience: z.string().max(2048).optional(),
|
|
824
|
+
description: z.string().max(1024).optional(),
|
|
825
|
+
confirmation
|
|
826
|
+
},
|
|
827
|
+
OVERWRITE_ANNOTATIONS,
|
|
828
|
+
async ({ name, token_service_url, client_id, client_secret, client_authentication, scope, scope_content_type, resource, audience, description, confirmation: confirmed }) => {
|
|
829
|
+
requireConfirmation(confirmed, `SET OAUTH2 CLIENT CREDENTIAL ${name}`);
|
|
830
|
+
try {
|
|
831
|
+
const outcome = await createOrReplace(`/OAuth2ClientCredentials(${key(name)})`, '/OAuth2ClientCredentials', existing => ({
|
|
832
|
+
Name: name,
|
|
833
|
+
Description: description ?? existing?.Description ?? '',
|
|
834
|
+
TokenServiceUrl: token_service_url,
|
|
835
|
+
ClientId: client_id,
|
|
836
|
+
ClientSecret: client_secret,
|
|
837
|
+
ClientAuthentication: client_authentication,
|
|
838
|
+
Scope: scope ?? '',
|
|
839
|
+
ScopeContentType: scope_content_type,
|
|
840
|
+
Resource: resource ?? '',
|
|
841
|
+
Audience: audience ?? ''
|
|
842
|
+
}));
|
|
843
|
+
return { [outcome]: true, name };
|
|
844
|
+
} catch (error) {
|
|
845
|
+
throw withoutSecret(error, client_secret);
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
register('cpi_upload_certificate', 'Upload certificate to keystore',
|
|
850
|
+
'Add a PEM certificate (max 10 KiB) to the tenant keystore under alias; pass update: true to replace an existing entry. Requires confirmation exactly equal to UPLOAD CERTIFICATE <alias>.',
|
|
851
|
+
{
|
|
852
|
+
alias: z.string().regex(/^[A-Za-z0-9_.-]{1,100}$/),
|
|
853
|
+
certificate: z.string()
|
|
854
|
+
.refine(value => Buffer.byteLength(value, 'utf8') <= MAX_CERTIFICATE_BYTES, 'must be at most 10 KiB')
|
|
855
|
+
.refine(value => value.includes('-----BEGIN CERTIFICATE-----'), 'must be a PEM certificate (-----BEGIN CERTIFICATE-----)'),
|
|
856
|
+
update: z.boolean().default(false),
|
|
857
|
+
confirmation
|
|
858
|
+
},
|
|
859
|
+
OVERWRITE_ANNOTATIONS,
|
|
860
|
+
async ({ alias, certificate, update, confirmation: confirmed }) => {
|
|
861
|
+
requireConfirmation(confirmed, `UPLOAD CERTIFICATE ${alias}`);
|
|
862
|
+
// Keystore keys are the hex of the UTF-8 alias because slashes are not allowed in the URI even when encoded.
|
|
863
|
+
const hexalias = Buffer.from(alias, 'utf8').toString('hex');
|
|
864
|
+
try {
|
|
865
|
+
await cpi('PUT', `/CertificateResources(${key(hexalias)})/$value`, {
|
|
866
|
+
query: { fingerprintVerified: 'true', returnKeystoreEntries: 'false', update: String(update) },
|
|
867
|
+
body: certificate,
|
|
868
|
+
contentType: 'application/pkix-cert',
|
|
869
|
+
responseType: 'text',
|
|
870
|
+
expect: [200, 204]
|
|
871
|
+
});
|
|
872
|
+
} catch (error) {
|
|
873
|
+
if (!update && /\(HTTP 400\)/.test(error?.message)) throw new IntegrationError(`Keystore alias ${alias} already exists; pass update: true to replace it`);
|
|
874
|
+
throw error;
|
|
875
|
+
}
|
|
876
|
+
return { uploaded: true, alias, hexalias, replaced: update };
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
// ---------------------------------------------------------------- number ranges, partner directory
|
|
880
|
+
|
|
881
|
+
register('cpi_list_number_ranges', 'List CPI number ranges',
|
|
882
|
+
'List number range objects (Name, Description, MinValue, MaxValue, CurrentValue = next value, Rotate, FieldLength, deployment).',
|
|
883
|
+
{},
|
|
884
|
+
READ_ONLY_ANNOTATIONS,
|
|
885
|
+
async () => list(await cpi('GET', '/NumberRanges')).map(range => ({
|
|
886
|
+
...pick(range, ['Name', 'Description', 'MinValue', 'MaxValue', 'CurrentValue', 'Rotate', 'FieldLength', 'DeployedBy']),
|
|
887
|
+
...dates(range, ['DeployedOn'])
|
|
888
|
+
})));
|
|
889
|
+
|
|
890
|
+
register('cpi_set_number_range', 'Set CPI number range',
|
|
891
|
+
'Create or replace a number range object. Values are non-negative integers of at most 14 digits with min_value ≤ current_value ≤ max_value; current_value is the next value handed out (default: kept on update, min_value on create). field_length 0 means no zero padding. Requires confirmation exactly equal to SET NUMBER RANGE <name>.',
|
|
892
|
+
z.object({
|
|
893
|
+
name: z.string().regex(/^[A-Za-z0-9 _.-]{1,120}$/),
|
|
894
|
+
description: z.string().max(1024).optional(),
|
|
895
|
+
min_value: numberText,
|
|
896
|
+
max_value: numberText,
|
|
897
|
+
current_value: numberText.optional(),
|
|
898
|
+
rotate: z.boolean().default(false),
|
|
899
|
+
field_length: z.number().int().min(0).max(14).default(0),
|
|
900
|
+
confirmation
|
|
901
|
+
}).strict().superRefine((value, context) => {
|
|
902
|
+
const min = BigInt(value.min_value);
|
|
903
|
+
if (min > BigInt(value.max_value)) context.addIssue({ code: 'custom', path: ['max_value'], message: 'max_value must be at least min_value' });
|
|
904
|
+
if (value.current_value !== undefined && (BigInt(value.current_value) < min || BigInt(value.current_value) > BigInt(value.max_value))) {
|
|
905
|
+
context.addIssue({ code: 'custom', path: ['current_value'], message: 'current_value must be between min_value and max_value' });
|
|
906
|
+
}
|
|
907
|
+
}),
|
|
908
|
+
OVERWRITE_ANNOTATIONS,
|
|
909
|
+
async ({ name, description, min_value, max_value, current_value, rotate, field_length, confirmation: confirmed }) => {
|
|
910
|
+
requireConfirmation(confirmed, `SET NUMBER RANGE ${name}`);
|
|
911
|
+
const [min, max] = [BigInt(min_value), BigInt(max_value)];
|
|
912
|
+
let body;
|
|
913
|
+
const outcome = await createOrReplace(`/NumberRanges(${key(name)})`, '/NumberRanges', existing => {
|
|
914
|
+
let current = current_value === undefined ? existing?.CurrentValue ?? min_value : current_value;
|
|
915
|
+
if (!/^\d{1,14}$/.test(String(current)) || BigInt(current) < min || BigInt(current) > max) {
|
|
916
|
+
throw new IntegrationError(`Current value ${current} of number range ${name} is outside ${min}..${max}; pass current_value`);
|
|
917
|
+
}
|
|
918
|
+
current = BigInt(current).toString();
|
|
919
|
+
body = {
|
|
920
|
+
Name: name,
|
|
921
|
+
Description: description ?? existing?.Description ?? '',
|
|
922
|
+
MinValue: min.toString(),
|
|
923
|
+
MaxValue: max.toString(),
|
|
924
|
+
CurrentValue: current,
|
|
925
|
+
Rotate: String(rotate),
|
|
926
|
+
FieldLength: String(field_length)
|
|
927
|
+
};
|
|
928
|
+
return body;
|
|
929
|
+
});
|
|
930
|
+
return { [outcome]: true, ...pick(body, ['Name', 'MinValue', 'MaxValue', 'CurrentValue', 'Rotate', 'FieldLength']) };
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
register('cpi_list_partner_parameters', 'List Partner Directory parameters',
|
|
934
|
+
'List Partner Directory string parameters (Pid, Id, Value) and binary parameter metadata (Pid, Id, ContentType; content is not returned), optionally for one partner id.',
|
|
935
|
+
{ pid: z.string().max(60).regex(partnerChars).optional(), top: topSchema(500, 200) },
|
|
936
|
+
READ_ONLY_ANNOTATIONS,
|
|
937
|
+
async ({ pid, top }) => {
|
|
938
|
+
const query = { $filter: pid ? `Pid eq ${odataKey(pid)}` : undefined, $top: top };
|
|
939
|
+
const audit = entry => ({ ...pick(entry, ['LastModifiedBy']), ...dates(entry, ['LastModifiedTime']) });
|
|
940
|
+
const strings = list(await cpi('GET', '/StringParameters', { query })).map(entry => ({ ...pick(entry, ['Pid', 'Id', 'Value']), ...audit(entry) }));
|
|
941
|
+
const binaries = list(await cpi('GET', '/BinaryParameters', { query })).map(entry => ({ ...pick(entry, ['Pid', 'Id', 'ContentType']), ...audit(entry) }));
|
|
942
|
+
return { string_parameters: strings, binary_parameters: binaries };
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
register('cpi_set_partner_parameter', 'Set Partner Directory parameter',
|
|
946
|
+
'Create or replace a Partner Directory string parameter (value up to 4000 characters) or binary parameter (base64 value up to 262144 characters, content_type required). Requires confirmation exactly equal to SET PARTNER PARAMETER <pid>/<id>.',
|
|
947
|
+
z.object({
|
|
948
|
+
pid: z.string().min(1).max(60).regex(partnerChars),
|
|
949
|
+
id: z.string().min(1).max(1500).regex(partnerChars),
|
|
950
|
+
type: z.enum(['string', 'binary']),
|
|
951
|
+
value: z.string().max(262_144),
|
|
952
|
+
content_type: z.enum(PARTNER_CONTENT_TYPES).optional(),
|
|
953
|
+
confirmation
|
|
954
|
+
}).strict().superRefine((value, context) => {
|
|
955
|
+
if (value.type === 'string') {
|
|
956
|
+
if (value.value.length > 4000) context.addIssue({ code: 'custom', path: ['value'], message: 'string parameter values are at most 4000 characters' });
|
|
957
|
+
if (value.content_type) context.addIssue({ code: 'custom', path: ['content_type'], message: 'content_type is only allowed for binary parameters' });
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
if (!value.content_type) context.addIssue({ code: 'custom', path: ['content_type'], message: 'content_type is required for binary parameters' });
|
|
961
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value.value) || value.value.length % 4 !== 0) {
|
|
962
|
+
context.addIssue({ code: 'custom', path: ['value'], message: 'binary parameter values must be base64' });
|
|
963
|
+
}
|
|
964
|
+
}),
|
|
965
|
+
OVERWRITE_ANNOTATIONS,
|
|
966
|
+
async ({ pid, id, type, value, content_type, confirmation: confirmed }) => {
|
|
967
|
+
requireConfirmation(confirmed, `SET PARTNER PARAMETER ${pid}/${id}`);
|
|
968
|
+
const set = type === 'string' ? 'StringParameters' : 'BinaryParameters';
|
|
969
|
+
const content = type === 'binary' ? { ContentType: content_type } : {};
|
|
970
|
+
const outcome = await createOrReplace(`/${set}(Pid=${key(pid)},Id=${key(id)})`, `/${set}`,
|
|
971
|
+
existing => (existing ? { Value: value, ...content } : { Pid: pid, Id: id, Value: value, ...content }),
|
|
972
|
+
{ putExpect: [200, 204], postExpect: [201] });
|
|
973
|
+
return { [outcome]: true, pid, id, type };
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
// ---------------------------------------------------------------- data stores, variables, JMS (the data store APIs accept no query options)
|
|
977
|
+
|
|
978
|
+
register('cpi_list_data_stores', 'List CPI data stores',
|
|
979
|
+
'List data stores with their iFlow, type, visibility and message counts; optionally only stores with overdue entries.',
|
|
980
|
+
{ overdue_only: z.boolean().default(false) },
|
|
981
|
+
READ_ONLY_ANNOTATIONS,
|
|
982
|
+
async ({ overdue_only }) => list(await cpi('GET', '/DataStores', { query: { overdueonly: overdue_only ? 'true' : undefined } }))
|
|
983
|
+
.map(store => pick(store, ['DataStoreName', 'IntegrationFlow', 'Type', 'Visibility', 'NumberOfMessages', 'NumberOfOverdueMessages'])));
|
|
984
|
+
|
|
985
|
+
register('cpi_list_data_store_entries', 'List data store entries',
|
|
986
|
+
`List entries of one data store (Id, Status, MessageId, due/created/retain dates; up to ${MAX_LIST_ENTRIES}). Entry payloads are never read. type is empty for Global and iFlow data stores.`,
|
|
987
|
+
{ data_store: z.string().min(1).max(200), integration_flow: z.string().min(1).max(200), type: z.string().max(40).default('') },
|
|
988
|
+
READ_ONLY_ANNOTATIONS,
|
|
989
|
+
async ({ data_store, integration_flow, type }) => {
|
|
990
|
+
const entries = list(await cpi('GET', `/DataStores(DataStoreName=${key(data_store)},IntegrationFlow=${key(integration_flow)},Type=${key(type)})/Entries`))
|
|
991
|
+
.map(entry => ({ ...pick(entry, ['Id', 'Status', 'MessageId']), ...dates(entry, ['DueAt', 'CreatedAt', 'RetainUntil']) }));
|
|
992
|
+
return { total: entries.length, returned: Math.min(entries.length, MAX_LIST_ENTRIES), entries: entries.slice(0, MAX_LIST_ENTRIES) };
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
register('cpi_list_variables', 'List CPI variables',
|
|
996
|
+
'List global and iFlow variables (name, iFlow, visibility, updated/retain dates). Values are never read.',
|
|
997
|
+
{},
|
|
998
|
+
READ_ONLY_ANNOTATIONS,
|
|
999
|
+
async () => list(await cpi('GET', '/Variables'))
|
|
1000
|
+
.map(variable => ({ ...pick(variable, ['VariableName', 'IntegrationFlow', 'Visibility']), ...dates(variable, ['UpdatedAt', 'RetainUntil']) })));
|
|
1001
|
+
|
|
1002
|
+
register('cpi_get_jms_broker_status', 'Get JMS broker status',
|
|
1003
|
+
'Return JMS broker capacity and limit flags (0 ok, 1 over limit) and the queues in warning (1) or critical (2) state.',
|
|
1004
|
+
{},
|
|
1005
|
+
READ_ONLY_ANNOTATIONS,
|
|
1006
|
+
async () => {
|
|
1007
|
+
const broker = await cpi('GET', `/JmsBrokers(${key('Broker1')})`, { query: { $expand: 'QueueStates' } });
|
|
1008
|
+
const queues = list(broker?.QueueStates);
|
|
1009
|
+
const unhealthy = queues.filter(queue => Number(queue.State) !== 0).map(queue => pick(queue, ['Name', 'State']));
|
|
1010
|
+
return { broker: pick(broker, JMS_BROKER_FIELDS), queue_count: queues.length, unhealthy_queue_count: unhealthy.length, unhealthy_queues: unhealthy };
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
register('cpi_get_message_log_steps', 'Get message processing steps',
|
|
1014
|
+
`Return the processing steps of the latest run of a message (StepId, ModelStepId, Activity, Status, Error, start/stop), ordered by start time, up to ${MAX_RUN_STEPS}. Payloads are never read.`,
|
|
1015
|
+
{ message_guid: messageGuid },
|
|
1016
|
+
READ_ONLY_ANNOTATIONS,
|
|
1017
|
+
async ({ message_guid }) => {
|
|
1018
|
+
const runs = list(await cpi('GET', `/MessageProcessingLogs(${key(message_guid)})/Runs`));
|
|
1019
|
+
if (!runs.length) return { run_count: 0, run: null, total_steps: 0, steps: [] };
|
|
1020
|
+
const latest = runs.reduce((best, run) => (time(run.RunStart) > time(best.RunStart) ? run : best));
|
|
1021
|
+
const steps = list(await cpi('GET', `/MessageProcessingLogRuns(${key(latest.Id)})/RunSteps`))
|
|
1022
|
+
.sort((left, right) => time(left.StepStart) - time(right.StepStart));
|
|
1023
|
+
return {
|
|
1024
|
+
run_count: runs.length,
|
|
1025
|
+
run: { ...pick(latest, ['Id', 'OverallState', 'LogLevel']), ...dates(latest, ['RunStart', 'RunStop']) },
|
|
1026
|
+
total_steps: steps.length,
|
|
1027
|
+
...(steps.length > MAX_RUN_STEPS ? { truncated: true } : {}),
|
|
1028
|
+
steps: steps.slice(0, MAX_RUN_STEPS).map(step => ({
|
|
1029
|
+
...pick(step, ['StepId', 'ModelStepId', 'Activity', 'Status']),
|
|
1030
|
+
Error: step.Error ? capText(String(step.Error), STEP_ERROR_CAP).text : null,
|
|
1031
|
+
...dates(step, ['StepStart', 'StepStop'])
|
|
1032
|
+
}))
|
|
1033
|
+
};
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/** Names of every cpi_* tool, in registration order. */
|
|
1038
|
+
export function cpiToolNames() {
|
|
1039
|
+
const names = [];
|
|
1040
|
+
registerCpiTools(name => names.push(name), null, {});
|
|
1041
|
+
return names;
|
|
1042
|
+
}
|