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.
Files changed (37) hide show
  1. package/.github/agents/abap-developer.agent.md +1 -1
  2. package/.github/agents/sap-integration-architect.agent.md +38 -0
  3. package/.github/agents/sap-integration-developer.agent.md +31 -0
  4. package/.github/agents/sap-solution-architect.agent.md +1 -0
  5. package/.github/skills/abap-development/SKILL.md +1 -1
  6. package/.github/skills/cds-development/SKILL.md +1 -1
  7. package/.github/skills/integration-suite-delivery/SKILL.md +74 -0
  8. package/.github/skills/sap-integration-architect/SKILL.md +76 -0
  9. package/NOTICE +10 -0
  10. package/README.md +36 -8
  11. package/dist/checksums.txt +5 -5
  12. package/dist/vsp-darwin-arm64 +0 -0
  13. package/dist/vsp-darwin-x64 +0 -0
  14. package/dist/vsp-linux-arm64 +0 -0
  15. package/dist/vsp-linux-x64 +0 -0
  16. package/dist/vsp-win32-x64.exe +0 -0
  17. package/inventory.md +66 -2
  18. package/package.json +5 -2
  19. package/patches/vsp-bas-proxy-auth.patch +88 -7
  20. package/scripts/run-live-integration.mjs +1079 -0
  21. package/src/apim-bundle.mjs +381 -0
  22. package/src/apim-tools.mjs +641 -0
  23. package/src/cpi-tools.mjs +1042 -0
  24. package/src/engineering-tools.mjs +105 -0
  25. package/src/iflow-bundle.mjs +282 -0
  26. package/src/integration-common.mjs +232 -0
  27. package/src/integration-config.mjs +167 -0
  28. package/src/integration-credentials.mjs +65 -0
  29. package/src/integration-http.mjs +266 -0
  30. package/src/integration-server.mjs +125 -0
  31. package/src/integration-templates/https-request-reply-http.iflw +391 -0
  32. package/src/integration-tools.mjs +27 -0
  33. package/src/integration-verify.mjs +118 -0
  34. package/src/launcher.mjs +14 -2
  35. package/src/mcp-config.mjs +18 -3
  36. package/src/setup.mjs +189 -11
  37. package/tools.md +81 -3
@@ -69,6 +69,30 @@ const TOOLS = {
69
69
  ['change_set_id'],
70
70
  { readOnlyHint: false, destructiveHint: true, idempotentHint: false }
71
71
  ),
72
+ UpdateTable: textTool(
73
+ 'UpdateTable',
74
+ 'Update and activate an existing Z/Y DDIC table from replacement DDL. The current DDL is re-read and compared with expected_source; VSP checks its source hash again under lock. Requires confirmation exactly equal to UPDATE TABLE <TABLE_NAME>.',
75
+ {
76
+ table_name: { type: 'string', minLength: 2, maxLength: 30, pattern: '^[ZzYy][A-Za-z0-9_]{1,29}$' },
77
+ expected_source: { type: 'string', minLength: 1, maxLength: MAX_CHANGE_BYTES },
78
+ replacement_source: { type: 'string', minLength: 1, maxLength: MAX_CHANGE_BYTES },
79
+ transport: { type: 'string', minLength: 1 },
80
+ confirmation: { type: 'string', minLength: 1, description: 'Must exactly equal UPDATE TABLE <TABLE_NAME>.' }
81
+ },
82
+ ['table_name', 'expected_source', 'replacement_source', 'confirmation'],
83
+ { readOnlyHint: false, destructiveHint: true, idempotentHint: false }
84
+ ),
85
+ DeleteEmptyDDLS: textTool(
86
+ 'DeleteEmptyDDLS',
87
+ 'Delete only an empty Z/Y DDLS shell. The source is read first and deletion is refused unless it is blank. Requires confirmation exactly equal to DELETE EMPTY DDLS <OBJECT_NAME>.',
88
+ {
89
+ name: { type: 'string', minLength: 2, maxLength: 30, pattern: '^[ZzYy][A-Za-z0-9_]{1,29}$' },
90
+ transport: { type: 'string', minLength: 1 },
91
+ confirmation: { type: 'string', minLength: 1, description: 'Must exactly equal DELETE EMPTY DDLS <OBJECT_NAME>.' }
92
+ },
93
+ ['name', 'confirmation'],
94
+ { readOnlyHint: false, destructiveHint: true, idempotentHint: false }
95
+ ),
72
96
  CheckTransportReadiness: textTool(
73
97
  'CheckTransportReadiness',
74
98
  'Collect a transport request and dependency, inactive-object, ABAP Unit, and ATC evidence bundle. Only registered read/check tools are accepted; inspect findings before handoff.',
@@ -237,6 +261,18 @@ function hashSource(source) {
237
261
  return createHash('sha256').update(source).digest('hex');
238
262
  }
239
263
 
264
+ function adtSourceHash(source) {
265
+ const canonical = source.replace(/\r\n/g, '\n').replace(/\n+$/g, '');
266
+ return `sha256:${createHash('sha256').update(canonical).digest('hex')}`;
267
+ }
268
+
269
+ function customerObjectName(value, label) {
270
+ if (typeof value !== 'string') throw new Error(`${label} must be a customer object name`);
271
+ const name = value.trim().toUpperCase();
272
+ if (!/^[ZY][A-Z0-9_]{1,29}$/.test(name)) throw new Error(`${label} must be a Z/Y customer object name of 2–30 letters, digits, or underscores`);
273
+ return name;
274
+ }
275
+
240
276
  function containsSource(value, source) {
241
277
  if (typeof value === 'string') return value === source;
242
278
  if (Array.isArray(value)) return value.some(entry => containsSource(entry, source));
@@ -519,6 +555,75 @@ export function createEngineeringTools(entry, upstreamTools, { env = process.env
519
555
  });
520
556
  }
521
557
 
558
+ if (available.has('GetTable') && available.has('WriteSource')) {
559
+ add(TOOLS.UpdateTable, async args => {
560
+ validateObject(args, 'arguments', ['table_name', 'expected_source', 'replacement_source', 'transport', 'confirmation']);
561
+ const tableName = customerObjectName(args.table_name, 'table_name');
562
+ if (args.confirmation !== `UPDATE TABLE ${tableName}`) throw new Error(`confirmation must equal UPDATE TABLE ${tableName}`);
563
+ for (const [label, source] of [['expected_source', args.expected_source], ['replacement_source', args.replacement_source]]) {
564
+ if (typeof source !== 'string' || !source) throw new Error(`${label} is required`);
565
+ if (Buffer.byteLength(source, 'utf8') > MAX_CHANGE_BYTES) throw new Error(`${label} exceeds ${MAX_CHANGE_BYTES} bytes`);
566
+ if (source.split(/\r?\n/).length > 1000) throw new Error(`${label} exceeds the 1,000-line limit`);
567
+ }
568
+ if (args.transport !== undefined && (typeof args.transport !== 'string' || !args.transport.trim())) throw new Error('transport must be a nonempty string when supplied');
569
+ const tableDefinition = /^\s*define\s+table\s+([A-Z0-9_/]+)/im.exec(args.replacement_source);
570
+ if (!tableDefinition || tableDefinition[1].toUpperCase() !== tableName) throw new Error(`replacement_source must define table ${tableName}`);
571
+ const expectedHash = adtSourceHash(args.expected_source);
572
+ if (expectedHash === adtSourceHash(args.replacement_source)) throw new Error('replacement_source does not change the table DDL');
573
+
574
+ const currentResult = await remoteTool(entry, 'GetTable', { table_name: tableName }, available, log);
575
+ const currentSource = sourceFromResult(currentResult);
576
+ if (currentSource === undefined) throw new Error(`GetTable did not return source for ${tableName}`);
577
+ const currentHash = adtSourceHash(currentSource);
578
+ if (currentHash !== expectedHash) throw new Error(`table source changed since review; re-read ${tableName} and prepare a new update`);
579
+
580
+ const writeArguments = {
581
+ object_type: 'TABL',
582
+ name: tableName,
583
+ source: args.replacement_source,
584
+ mode: 'update',
585
+ expected_source_hash: currentHash,
586
+ ...(args.transport ? { transport: args.transport.trim() } : {})
587
+ };
588
+ const writeResult = await remoteTool(entry, 'WriteSource', writeArguments, available, log);
589
+ let writeDetails;
590
+ try {
591
+ writeDetails = JSON.parse(resultData(writeResult));
592
+ } catch {
593
+ throw new Error(`WriteSource returned an unparseable result for ${tableName}; inspect SAP state before retrying`);
594
+ }
595
+ if (!object(writeDetails) || writeDetails.success !== true) throw new Error(`WriteSource did not confirm a successful table update for ${tableName}; inspect SAP state before retrying`);
596
+ return toolJson({
597
+ status: 'updated',
598
+ table_name: tableName,
599
+ expected_source_hash: currentHash,
600
+ target_source_hash: adtSourceHash(args.replacement_source),
601
+ result: writeDetails
602
+ });
603
+ });
604
+ }
605
+
606
+ if (available.has('GetSource') && available.has('DeleteObject')) {
607
+ add(TOOLS.DeleteEmptyDDLS, async args => {
608
+ validateObject(args, 'arguments', ['name', 'transport', 'confirmation']);
609
+ const name = customerObjectName(args.name, 'name');
610
+ if (args.confirmation !== `DELETE EMPTY DDLS ${name}`) throw new Error(`confirmation must equal DELETE EMPTY DDLS ${name}`);
611
+ if (args.transport !== undefined && (typeof args.transport !== 'string' || !args.transport.trim())) throw new Error('transport must be a nonempty string when supplied');
612
+
613
+ const sourceResult = await remoteTool(entry, 'GetSource', { object_type: 'DDLS', name, include_context: false }, available, log);
614
+ const source = sourceFromResult(sourceResult);
615
+ if (source === undefined) throw new Error(`GetSource did not return source for DDLS ${name}; refusing deletion`);
616
+ if (source.trim() !== '') throw new Error(`DDLS ${name} is not empty; refusing deletion`);
617
+
618
+ const objectUrl = `/sap/bc/adt/ddic/ddl/sources/${encodeURIComponent(name.toLowerCase())}`;
619
+ await remoteTool(entry, 'DeleteObject', {
620
+ object_url: objectUrl,
621
+ ...(args.transport ? { transport: args.transport.trim() } : {})
622
+ }, available, log);
623
+ return toolJson({ status: 'deleted', object_type: 'DDLS', name, object_url: objectUrl });
624
+ });
625
+ }
626
+
522
627
  if (available.has('GetTransport')) {
523
628
  add(TOOLS.CheckTransportReadiness, async args => {
524
629
  validateObject(args, 'arguments', ['transport_id', 'checks']);
@@ -0,0 +1,282 @@
1
+ // Builds Cloud Integration iFlow bundles (zip) for the CPI OData API.
2
+ // Pure: no network. Manifest import headers follow FlashPipe's tenant-verified
3
+ // test artifact (Apache-2.0, see NOTICE).
4
+ import { readFileSync } from 'node:fs';
5
+ import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate';
6
+ import { IntegrationError } from './integration-http.mjs';
7
+
8
+ export const IFLOW_TEMPLATES = Object.freeze(['https-request-reply-http']);
9
+
10
+ const IMPORT_PACKAGE = "com.sap.esb.application.services.cxf.interceptor,com.sap.esb.security,com.sap.it.op.agent.api,com.sap.it.op.agent.collector.camel,com.sap.it.op.agent.collector.cxf,com.sap.it.op.agent.mpl,javax.jms,javax.jws,javax.wsdl,javax.xml.bind.annotation,javax.xml.namespace,javax.xml.ws,org.apache.camel;version=\"2.8\",org.apache.camel.builder;version=\"2.8\",org.apache.camel.builder.xml;version=\"2.8\",org.apache.camel.component.cxf,org.apache.camel.model;version=\"2.8\",org.apache.camel.processor;version=\"2.8\",org.apache.camel.processor.aggregate;version=\"2.8\",org.apache.camel.spring.spi;version=\"2.8\",org.apache.commons.logging,org.apache.cxf.binding,org.apache.cxf.binding.soap,org.apache.cxf.binding.soap.spring,org.apache.cxf.bus,org.apache.cxf.bus.resource,org.apache.cxf.bus.spring,org.apache.cxf.buslifecycle,org.apache.cxf.catalog,org.apache.cxf.configuration.jsse;version=\"2.5\",org.apache.cxf.configuration.spring,org.apache.cxf.endpoint,org.apache.cxf.headers,org.apache.cxf.interceptor,org.apache.cxf.management.counters;version=\"2.5\",org.apache.cxf.message,org.apache.cxf.phase,org.apache.cxf.resource,org.apache.cxf.service.factory,org.apache.cxf.service.model,org.apache.cxf.transport,org.apache.cxf.transport.common.gzip,org.apache.cxf.transport.http,org.apache.cxf.transport.http.policy,org.apache.cxf.workqueue,org.apache.cxf.ws.rm.persistence,org.apache.cxf.wsdl11,org.osgi.framework;version=\"1.6.0\",org.slf4j;version=\"1.6\",org.springframework.beans.factory.config;version=\"3.0\",com.sap.esb.camel.security.cms,org.apache.camel.spi,com.sap.esb.webservice.audit.log,com.sap.esb.camel.endpoint.configurator.api,com.sap.esb.camel.jdbc.idempotency.reorg,javax.sql,org.apache.camel.processor.idempotent.jdbc,org.osgi.service.blueprint;version=\"[1.0.0,2.0.0)\"";
11
+ const IMPORT_SERVICE = "com.sap.esb.webservice.audit.log.AuditLogger,com.sap.esb.security.KeyManagerFactory;multiple:=false,com.sap.esb.security.TrustManagerFactory;multiple:=false,javax.sql.DataSource;multiple:=false;filter=\"(dataSourceName=default)\",org.apache.cxf.ws.rm.persistence.RMStore;multiple:=false,com.sap.esb.camel.security.cms.SignatureSplitter;multiple:=false";
12
+
13
+ const IFLOW_DIR = 'src/main/resources/scenarioflows/integrationflow';
14
+ const PARAMETERS_PROP = 'src/main/resources/parameters.prop';
15
+ const PARAMETERS_PROPDEF = 'src/main/resources/parameters.propdef';
16
+ const MANIFEST_PATH = 'META-INF/MANIFEST.MF';
17
+ const MANIFEST_LINE_BYTES = 70;
18
+ const MAX_IFLW_BYTES = 256 * 1024;
19
+ const MAX_SCRIPT_BYTES = 128 * 1024;
20
+ const MAX_SCRIPTS = 10;
21
+ const MAX_PARAMETERS = 50;
22
+ const SCRIPT_NAME = /^[A-Za-z0-9_.-]{1,80}\.(groovy|gsh|js)$/;
23
+ const PARAMETER_KEY = /^[A-Za-z0-9_. -]{1,100}$/;
24
+ const VERSION = /^(\d+)\.(\d+)\.(\d+)$/;
25
+
26
+ const templateCache = new Map();
27
+
28
+ function templateText(template) {
29
+ if (!IFLOW_TEMPLATES.includes(template)) throw new IntegrationError(`Unknown iFlow template ${template}`);
30
+ if (!templateCache.has(template)) {
31
+ templateCache.set(template, readFileSync(new URL(`./integration-templates/${template}.iflw`, import.meta.url), 'utf8'));
32
+ }
33
+ return templateCache.get(template);
34
+ }
35
+
36
+ function utf8Chunks(text, limit) {
37
+ // Split on UTF-8 byte boundaries without breaking a multi-byte character.
38
+ const chunks = [];
39
+ let current = '';
40
+ let size = 0;
41
+ for (const char of text) {
42
+ const width = Buffer.byteLength(char, 'utf8');
43
+ if (size + width > limit && current) {
44
+ chunks.push(current);
45
+ current = '';
46
+ size = 0;
47
+ }
48
+ current += char;
49
+ size += width;
50
+ }
51
+ if (current || !chunks.length) chunks.push(current);
52
+ return chunks;
53
+ }
54
+
55
+ /** Serialize ordered [name, value] headers with JAR line wrapping and CRLF endings. */
56
+ export function manifestText(headers) {
57
+ const lines = [];
58
+ for (const [name, value] of headers) {
59
+ const [first, ...rest] = utf8Chunks(`${name}: ${value}`, MANIFEST_LINE_BYTES);
60
+ lines.push(first);
61
+ if (rest.length) {
62
+ const tail = rest.join('');
63
+ for (const chunk of utf8Chunks(tail, MANIFEST_LINE_BYTES - 1)) lines.push(` ${chunk}`);
64
+ }
65
+ }
66
+ return `${lines.join('\r\n')}\r\n`;
67
+ }
68
+
69
+ function parseManifest(text) {
70
+ const unwrapped = text.replace(/\r\n/g, '\n').replace(/\n /g, '');
71
+ return unwrapped.split('\n').filter(Boolean).map(line => {
72
+ const index = line.indexOf(': ');
73
+ return index < 0 ? [line, ''] : [line.slice(0, index), line.slice(index + 2)];
74
+ });
75
+ }
76
+
77
+ /** Replace Bundle-Version in a (possibly wrapped) manifest and re-wrap it. */
78
+ export function replaceManifestVersion(text, newVersion) {
79
+ const headers = parseManifest(text);
80
+ const entry = headers.find(([name]) => name === 'Bundle-Version');
81
+ if (entry) entry[1] = newVersion;
82
+ else headers.push(['Bundle-Version', newVersion]);
83
+ return manifestText(headers);
84
+ }
85
+
86
+ export function bumpPatchVersion(version) {
87
+ const match = VERSION.exec(String(version));
88
+ if (!match) throw new IntegrationError(`iFlow version ${version} is not x.y.z; update it in the Integration Suite UI first`);
89
+ return `${match[1]}.${match[2]}.${Number(match[3]) + 1}`;
90
+ }
91
+
92
+ // Java .properties are read as ISO-8859-1 by CPI (verified live: raw UTF-8 'ü' came back as 'ü'),
93
+ // so everything outside printable ASCII is written as \uXXXX UTF-16 code units, as Properties.store does.
94
+ function escapeProperty(text, isKey) {
95
+ const value = String(text);
96
+ let escaped = '';
97
+ for (let index = 0; index < value.length; index += 1) {
98
+ const char = value[index];
99
+ const code = value.charCodeAt(index);
100
+ if (char === '\\' || char === ':' || char === '=' || char === '#' || char === '!') escaped += `\\${char}`;
101
+ else if (char === ' ' && (isKey || index === 0)) escaped += '\\ ';
102
+ else if (char === '\n') escaped += '\\n';
103
+ else if (char === '\r') escaped += '\\r';
104
+ else if (char === '\t') escaped += '\\t';
105
+ else if (code < 0x20 || code > 0x7e) escaped += `\\u${code.toString(16).toUpperCase().padStart(4, '0')}`;
106
+ else escaped += char;
107
+ }
108
+ return escaped;
109
+ }
110
+
111
+ function unescapeProperty(text) {
112
+ return text.replace(/\\(?:u([0-9a-fA-F]{4})|(.))/g, (_, hex, char) => {
113
+ if (hex) return String.fromCharCode(Number.parseInt(hex, 16));
114
+ return ({ n: '\n', r: '\r', t: '\t', f: '\f' })[char] ?? char;
115
+ });
116
+ }
117
+
118
+ /** Serialize a parameter map in Java properties format. */
119
+ export function propertiesText(parameters) {
120
+ return Object.entries(parameters).map(([key, value]) => `${escapeProperty(key, true)}=${escapeProperty(value, false)}\n`).join('');
121
+ }
122
+
123
+ /** Parse a Java properties file (key=value lines, comments, escaped separators). */
124
+ export function parseProperties(text) {
125
+ const result = {};
126
+ const logical = text.replace(/\r\n/g, '\n').replace(/\\\n[ \t]*/g, '');
127
+ for (const rawLine of logical.split('\n')) {
128
+ const line = rawLine.replace(/^[ \t]+/, '');
129
+ if (!line || line.startsWith('#') || line.startsWith('!')) continue;
130
+ let index = 0;
131
+ while (index < line.length) {
132
+ const char = line[index];
133
+ if (char === '\\') { index += 2; continue; }
134
+ if (char === '=' || char === ':' || char === ' ' || char === '\t') break;
135
+ index += 1;
136
+ }
137
+ const key = unescapeProperty(line.slice(0, index));
138
+ const value = line.slice(index).replace(/^[ \t]*[=:]?[ \t]*/, '');
139
+ result[key] = unescapeProperty(value);
140
+ }
141
+ return result;
142
+ }
143
+
144
+ function xmlEscape(value) {
145
+ return String(value)
146
+ .replaceAll('&', '&amp;')
147
+ .replaceAll('<', '&lt;')
148
+ .replaceAll('>', '&gt;')
149
+ .replaceAll('"', '&quot;')
150
+ .replaceAll("'", '&apos;');
151
+ }
152
+
153
+ export function propdefEntry(key) {
154
+ return `<parameter><key/><name>${xmlEscape(key)}</name><type>xsd:string</type><isRequired>false</isRequired><constraint/><description/><additionalMetadata/></parameter>`;
155
+ }
156
+
157
+ export function propdefText(keys) {
158
+ return `<?xml version="1.0" encoding="UTF-8" standalone="no"?><parameters>${keys.map(propdefEntry).join('')}<param_references/></parameters>`;
159
+ }
160
+
161
+ /** Append propdef entries for keys not yet declared; keeps existing content intact. */
162
+ export function mergePropdef(text, keys) {
163
+ const declared = new Set([...String(text).matchAll(/<name>([\s\S]*?)<\/name>/g)].map(match => match[1]
164
+ .replaceAll('&lt;', '<').replaceAll('&gt;', '>').replaceAll('&quot;', '"').replaceAll('&apos;', "'").replaceAll('&amp;', '&')));
165
+ const missing = keys.filter(key => !declared.has(key));
166
+ if (!missing.length) return text;
167
+ if (!text || !text.includes('<parameters')) return propdefText(keys);
168
+ const entries = missing.map(propdefEntry).join('');
169
+ if (text.includes('<param_references')) return text.replace('<param_references', `${entries}<param_references`);
170
+ if (/<parameters\s*\/>/.test(text)) return text.replace(/<parameters\s*\/>/, `<parameters>${entries}</parameters>`);
171
+ return text.replace('</parameters>', `${entries}</parameters>`);
172
+ }
173
+
174
+ export function validateParameters(parameters) {
175
+ const entries = Object.entries(parameters || {});
176
+ if (entries.length > MAX_PARAMETERS) throw new IntegrationError(`At most ${MAX_PARAMETERS} parameters are supported`);
177
+ for (const [key, value] of entries) {
178
+ if (!PARAMETER_KEY.test(key)) throw new IntegrationError(`Parameter key ${JSON.stringify(key).slice(0, 120)} must match ${PARAMETER_KEY.source}`);
179
+ if (typeof value !== 'string') throw new IntegrationError(`Parameter ${key} must be a string`);
180
+ }
181
+ return entries;
182
+ }
183
+
184
+ function validateScripts(scripts) {
185
+ if (scripts.length > MAX_SCRIPTS) throw new IntegrationError(`At most ${MAX_SCRIPTS} scripts are supported`);
186
+ const seen = new Set();
187
+ for (const script of scripts) {
188
+ if (!SCRIPT_NAME.test(script?.name || '')) throw new IntegrationError(`Script name must match ${SCRIPT_NAME.source}`);
189
+ if (seen.has(script.name)) throw new IntegrationError(`Duplicate script ${script.name}`);
190
+ seen.add(script.name);
191
+ if (typeof script.content !== 'string' || Buffer.byteLength(script.content, 'utf8') > MAX_SCRIPT_BYTES) {
192
+ throw new IntegrationError(`Script ${script.name} must be text up to ${MAX_SCRIPT_BYTES / 1024} KiB`);
193
+ }
194
+ }
195
+ }
196
+
197
+ function templateParameterMap(parameters) {
198
+ if (!parameters || typeof parameters !== 'object') throw new IntegrationError('template_parameters are required for template iFlows');
199
+ return {
200
+ Sender_Path: parameters.sender_path,
201
+ Receiver_Address: parameters.receiver_address,
202
+ Receiver_Query: parameters.receiver_query ?? '',
203
+ Receiver_Method: parameters.receiver_method,
204
+ Receiver_Proxy_Type: parameters.receiver_proxy_type === 'on_premise' ? 'sapcc' : 'default',
205
+ Receiver_Location_ID: parameters.receiver_location_id ?? '',
206
+ Receiver_Authentication: parameters.receiver_authentication,
207
+ Receiver_Credential_Name: parameters.receiver_credential_name ?? '',
208
+ Receiver_Timeout: String(parameters.receiver_timeout_ms)
209
+ };
210
+ }
211
+
212
+ /** Build an iFlow bundle zip from a vetted template or caller-supplied BPMN. */
213
+ export function buildIflowBundle({
214
+ id,
215
+ name,
216
+ description = '',
217
+ version = '1.0.0',
218
+ template,
219
+ templateParameters,
220
+ iflwXml,
221
+ scripts = [],
222
+ parameters = {}
223
+ }) {
224
+ let iflw;
225
+ let merged;
226
+ if (template && template !== 'custom') {
227
+ iflw = templateText(template);
228
+ merged = { ...templateParameterMap(templateParameters), ...parameters };
229
+ } else {
230
+ if (typeof iflwXml !== 'string' || !iflwXml.includes('<bpmn2:definitions') || Buffer.byteLength(iflwXml, 'utf8') > MAX_IFLW_BYTES) {
231
+ throw new IntegrationError('iflw_xml must be a BPMN2 iFlow model (bpmn2:definitions) up to 256 KiB');
232
+ }
233
+ iflw = iflwXml;
234
+ merged = { ...parameters };
235
+ }
236
+ for (const [key, value] of Object.entries(merged)) {
237
+ if (value === undefined || value === null) throw new IntegrationError(`Parameter ${key} is required`);
238
+ merged[key] = String(value);
239
+ }
240
+ const parameterEntries = validateParameters(merged);
241
+ validateScripts(scripts);
242
+
243
+ const files = {
244
+ [MANIFEST_PATH]: strToU8(manifestText([
245
+ ['Manifest-Version', '1.0'],
246
+ ['SAP-RuntimeProfile', 'iflmap'],
247
+ ['Bundle-SymbolicName', `${id}; singleton:=true`],
248
+ ['Bundle-Name', name],
249
+ ['Bundle-Version', version],
250
+ ['Bundle-ManifestVersion', '2'],
251
+ ['Origin-Bundle-SymbolicName', id],
252
+ ['SAP-NodeType', 'IFLMAP'],
253
+ ['Import-Package', IMPORT_PACKAGE],
254
+ ['Origin-Bundle-Name', name],
255
+ ['SAP-BundleType', 'IntegrationFlow'],
256
+ ['Import-Service', IMPORT_SERVICE]
257
+ ])),
258
+ 'metainfo.prop': strToU8(`description=${String(description).replace(/[\r\n]+/g, ' ')}\n`),
259
+ [`${IFLOW_DIR}/${id}.iflw`]: strToU8(iflw)
260
+ };
261
+ for (const script of scripts) files[`src/main/resources/script/${script.name}`] = strToU8(script.content);
262
+ if (parameterEntries.length) {
263
+ files[PARAMETERS_PROP] = strToU8(propertiesText(merged));
264
+ files[PARAMETERS_PROPDEF] = strToU8(propdefText(parameterEntries.map(([key]) => key)));
265
+ }
266
+ return zipSync(files);
267
+ }
268
+
269
+ /** Unzip a bundle into path → bytes. */
270
+ export function readBundle(bytes) {
271
+ try {
272
+ return new Map(Object.entries(unzipSync(bytes)));
273
+ } catch {
274
+ throw new IntegrationError('iFlow bundle is not a readable zip archive');
275
+ }
276
+ }
277
+
278
+ export function writeBundle(files) {
279
+ return zipSync(Object.fromEntries(files));
280
+ }
281
+
282
+ export { strFromU8, strToU8, MANIFEST_PATH, PARAMETERS_PROP, PARAMETERS_PROPDEF };
@@ -0,0 +1,232 @@
1
+ // Shared plumbing for the Integration Suite MCP tools: result shaping, OData entity cleanup,
2
+ // secret redaction, common input schemas, and a guarded read-only OData GET tool.
3
+ import { z } from 'zod';
4
+ import { INTEGRATION_CONFIG_ERROR } from './integration-config.mjs';
5
+ import { IntegrationError, odataKey } from './integration-http.mjs';
6
+ import { strFromU8 } from './iflow-bundle.mjs';
7
+
8
+ export const INTEGRATION_MAX_OUTPUT_BYTES = 96 * 1024;
9
+ export const FILE_TEXT_CAP = 48 * 1024;
10
+ export const SINGLE_FILE_CAP = 90 * 1024;
11
+ export const ERROR_TEXT_CAP = 16 * 1024;
12
+ const TEXT_EXTENSIONS = new Set(['.iflw', '.groovy', '.gsh', '.js', '.py', '.xsl', '.xslt', '.xsd', '.wsdl', '.prop', '.propdef', '.mmap', '.json', '.xml', '.MF', '.project', '.edmx']);
13
+
14
+ export const READ_ONLY_ANNOTATIONS = Object.freeze({ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true });
15
+ export const CREATE_ANNOTATIONS = Object.freeze({ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true });
16
+ export const OVERWRITE_ANNOTATIONS = Object.freeze({ readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true });
17
+
18
+ // ---------------------------------------------------------------- results
19
+
20
+ function jsonSafe(value, seen = new WeakSet()) {
21
+ if (typeof value === 'bigint') return value.toString();
22
+ if (value instanceof Date) return value.toISOString();
23
+ if (value instanceof Uint8Array) return { type: 'binary', bytes: value.byteLength };
24
+ if (Array.isArray(value)) return value.map(entry => jsonSafe(entry, seen));
25
+ if (value && typeof value === 'object') {
26
+ if (seen.has(value)) return '[circular]';
27
+ seen.add(value);
28
+ const result = Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, jsonSafe(entry, seen)]));
29
+ seen.delete(value);
30
+ return result;
31
+ }
32
+ if (typeof value === 'number' && !Number.isFinite(value)) return String(value);
33
+ return value;
34
+ }
35
+
36
+ function jsonText(value) {
37
+ const text = JSON.stringify(jsonSafe(value), null, 2);
38
+ if (Buffer.byteLength(text, 'utf8') > INTEGRATION_MAX_OUTPUT_BYTES) {
39
+ throw new IntegrationError('Integration Suite result is too large to return safely; narrow the request (for example file_path, $select or a smaller top).');
40
+ }
41
+ return text;
42
+ }
43
+
44
+ export function toolError(error) {
45
+ let message = 'Integration Suite request failed. Check service-key roles and tenant reachability.';
46
+ if (error?.name === 'IntegrationError' || (typeof error?.message === 'string' && INTEGRATION_CONFIG_ERROR.test(error.message))) {
47
+ message = error.message;
48
+ }
49
+ return { isError: true, content: [{ type: 'text', text: message }] };
50
+ }
51
+
52
+ export async function resultOf(operation) {
53
+ try {
54
+ return { content: [{ type: 'text', text: jsonText(await operation()) }] };
55
+ } catch (error) {
56
+ return toolError(error);
57
+ }
58
+ }
59
+
60
+ // ---------------------------------------------------------------- helpers
61
+
62
+ export const key = value => encodeURIComponent(odataKey(value));
63
+ export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
64
+
65
+ export function requireConfirmation(actual, expected) {
66
+ if (actual !== expected) throw new IntegrationError(`confirmation must equal ${expected}`);
67
+ }
68
+
69
+ export function list(value) {
70
+ if (Array.isArray(value)) return value;
71
+ if (Array.isArray(value?.results)) return value.results;
72
+ return [];
73
+ }
74
+
75
+ /** Drop OData plumbing (__metadata, deferred navigation links) from an entity. */
76
+ export function entity(value) {
77
+ if (Array.isArray(value)) return value.map(entity);
78
+ if (!value || typeof value !== 'object') return value;
79
+ const result = {};
80
+ for (const [name, entry] of Object.entries(value)) {
81
+ if (name.startsWith('__')) continue;
82
+ if (entry && typeof entry === 'object' && !Array.isArray(entry) && entry.__deferred) continue;
83
+ result[name] = entry && typeof entry === 'object' && Array.isArray(entry.results) ? entity(entry.results) : entity(entry);
84
+ }
85
+ return result;
86
+ }
87
+
88
+ const PROVIDER_SECRET_KEYS = new Set(['password', 'clientSecret', 'userSecret', 'orgSecret', 'keystorePassword', 'userName', 'clientId']);
89
+
90
+ /** Remove API provider credential properties (including user and client ids) entirely. */
91
+ export function withoutSecrets(value) {
92
+ if (Array.isArray(value)) return value.map(withoutSecrets);
93
+ if (!value || typeof value !== 'object') return value;
94
+ return Object.fromEntries(Object.entries(value)
95
+ .filter(([name]) => !PROVIDER_SECRET_KEYS.has(name))
96
+ .map(([name, entry]) => [name, withoutSecrets(entry)]));
97
+ }
98
+
99
+ export const REDACTED = '[redacted]';
100
+ const SECRET_FIELD = /password|secret|passphrase|private_?key|api_?key|app_?key|consumer_?key|secure_?param|access_?token|refresh_?token/i;
101
+ const BINARY_FIELDS = new Set(['ArtifactContent', 'ResourceContent', 'PackageContent', 'CustomTagsConfigurationContent']);
102
+
103
+ /**
104
+ * Replace secret-looking values with [redacted] and omit base64 artifact content. Inside an object flagged
105
+ * encrypted/isEncrypted (APIM key value maps) every nested `value` is redacted as well.
106
+ */
107
+ export function redactSecrets(value, encrypted = false) {
108
+ if (Array.isArray(value)) return value.map(entry => redactSecrets(entry, encrypted));
109
+ if (!value || typeof value !== 'object') return value;
110
+ const inEncrypted = encrypted || value.encrypted === true || value.isEncrypted === true;
111
+ const result = {};
112
+ for (const [name, entry] of Object.entries(value)) {
113
+ if (BINARY_FIELDS.has(name)) {
114
+ if (entry !== null && entry !== undefined && entry !== '') result[name] = '[binary omitted]';
115
+ continue;
116
+ }
117
+ if (entry !== null && entry !== undefined && entry !== '' && typeof entry !== 'object'
118
+ && (SECRET_FIELD.test(name) || (inEncrypted && name === 'value'))) {
119
+ result[name] = REDACTED;
120
+ continue;
121
+ }
122
+ result[name] = redactSecrets(entry, inEncrypted);
123
+ }
124
+ return result;
125
+ }
126
+
127
+ export function pick(value, names) {
128
+ return Object.fromEntries(names.filter(name => value?.[name] !== undefined).map(name => [name, value[name]]));
129
+ }
130
+
131
+ export function capText(text, limit) {
132
+ if (typeof text !== 'string') return { text, truncated: false };
133
+ const bytes = Buffer.from(text, 'utf8');
134
+ if (bytes.byteLength <= limit) return { text, truncated: false };
135
+ return { text: new TextDecoder().decode(bytes.subarray(0, limit)).replace(/\uFFFD$/, ''), truncated: true };
136
+ }
137
+
138
+ export function isoToOdataDateTime(value) {
139
+ return new Date(value).toISOString().slice(0, 19);
140
+ }
141
+
142
+ function extensionOf(path) {
143
+ const name = path.slice(path.lastIndexOf('/') + 1);
144
+ const index = name.lastIndexOf('.');
145
+ return index < 0 ? '' : name.slice(index);
146
+ }
147
+
148
+ export function fileEntry(path, bytes, limit) {
149
+ const entry = { path, bytes: bytes.byteLength };
150
+ if (TEXT_EXTENSIONS.has(extensionOf(path))) {
151
+ const { text, truncated } = capText(strFromU8(bytes), limit);
152
+ entry.text = text;
153
+ if (truncated) entry.truncated = true;
154
+ }
155
+ return entry;
156
+ }
157
+
158
+ /** Files of an unzipped bundle (Map path → bytes), or one file when filePath is set. */
159
+ export function bundleFiles(bundle, filePath) {
160
+ if (filePath) {
161
+ const bytes = bundle.get(filePath);
162
+ if (!bytes) throw new IntegrationError('file_path not found in the bundle');
163
+ return { file: fileEntry(filePath, bytes, SINGLE_FILE_CAP) };
164
+ }
165
+ return {
166
+ files: [...bundle.entries()]
167
+ .filter(([name]) => !name.endsWith('/'))
168
+ .map(([name, bytes]) => fileEntry(name, bytes, FILE_TEXT_CAP))
169
+ };
170
+ }
171
+
172
+ // ---------------------------------------------------------------- schemas
173
+
174
+ export const confirmation = z.string().min(1).max(300);
175
+ export const isoDate = z.string().max(40).refine(value => !Number.isNaN(Date.parse(value)), 'must be an ISO-8601 date/time');
176
+ export const top = (max = 200, fallback = 100) => z.number().int().min(1).max(max).default(fallback);
177
+ export const secretValue = z.string().min(1).max(4096)
178
+ .describe('Write-only: sent to the tenant once and never echoed back by any tool.');
179
+
180
+ // ---------------------------------------------------------------- read-only OData GET
181
+
182
+ const ODATA_SEGMENT = String.raw`[A-Za-z][A-Za-z0-9_]{0,99}(?:\([^/?#]{0,400}\))?`;
183
+ const ODATA_PATH = new RegExp(String.raw`^(?:/\$metadata|/${ODATA_SEGMENT}(?:/(?:${ODATA_SEGMENT}|\$count))*)$`);
184
+ const ODATA_OPTIONS = ['$filter', '$select', '$expand', '$orderby', '$top', '$skip', '$inlinecount'];
185
+
186
+ /** Summarize an OData $metadata document into entity sets and function imports. */
187
+ export function metadataSummary(xml) {
188
+ const attribute = (tag, name) => tag.match(new RegExp(`\\b${name}="([^"]*)"`))?.[1];
189
+ const entitySets = [...String(xml).matchAll(/<EntitySet\b[^>]*>/g)]
190
+ .map(([tag]) => ({ name: attribute(tag, 'Name'), entity_type: attribute(tag, 'EntityType') }))
191
+ .filter(entry => entry.name);
192
+ const functionImports = [...String(xml).matchAll(/<FunctionImport\b[^>]*>/g)]
193
+ .map(([tag]) => ({ name: attribute(tag, 'Name'), http_method: attribute(tag, 'm:HttpMethod') ?? null, return_type: attribute(tag, 'ReturnType') ?? null }))
194
+ .filter(entry => entry.name);
195
+ return { entity_sets: entitySets, function_imports: functionImports };
196
+ }
197
+
198
+ /**
199
+ * Register a guarded read-only GET over one OData service. Media ($value), $batch and $links
200
+ * are rejected so payloads and binary content stay out of reach; secrets are redacted.
201
+ */
202
+ export function registerODataReader(register, { name, title, service, request, examples }) {
203
+ register(name, title,
204
+ `Read-only GET against the ${service} OData API for entity sets without a dedicated tool. path is relative to the service root, e.g. ${examples}. Use /$metadata to list entity sets and function imports. Media streams ($value), $batch and $links are not allowed; secret-like fields and artifact content are redacted.`,
205
+ {
206
+ path: z.string().max(1000).regex(ODATA_PATH, 'must be /$metadata or /EntitySet[(key)][/Navigation...][/$count] without $value, query string or ..')
207
+ .refine(value => !value.includes('..'), 'must not contain ..'),
208
+ query: z.record(z.string().regex(/^(?:\$[a-z]+|[A-Za-z][A-Za-z0-9_]{0,40})$/), z.string().max(2000)).optional()
209
+ .describe(`OData system options (${ODATA_OPTIONS.join(', ')}) and service-specific custom parameters (e.g. keystoreName).`)
210
+ },
211
+ READ_ONLY_ANNOTATIONS,
212
+ async ({ path, query = {} }) => {
213
+ for (const option of Object.keys(query)) {
214
+ if (option.startsWith('$') && !ODATA_OPTIONS.includes(option)) throw new IntegrationError(`query option ${option} is not allowed`);
215
+ }
216
+ if (path === '/$metadata') {
217
+ return metadataSummary(await request('GET', path, { accept: 'application/xml', responseType: 'text' }));
218
+ }
219
+ if (path.endsWith('/$count')) {
220
+ const text = await request('GET', path, { query, accept: 'text/plain', responseType: 'text' });
221
+ return { count: Number(String(text).trim()) };
222
+ }
223
+ const parsed = await request('GET', path, { query, unwrap: false });
224
+ const data = parsed?.d ?? parsed;
225
+ const total = Number(data?.__count);
226
+ const results = Array.isArray(data?.results) ? data.results : null;
227
+ const cleaned = redactSecrets(entity(results ?? data));
228
+ return results
229
+ ? { total: Number.isFinite(total) ? total : null, returned: results.length, more: Boolean(data?.__next), results: cleaned }
230
+ : cleaned;
231
+ });
232
+ }