sap-ai-dev-toolkit 0.9.9913 → 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/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/integration-suite-delivery/SKILL.md +74 -0
- package/.github/skills/sap-integration-architect/SKILL.md +76 -0
- package/NOTICE +10 -0
- package/README.md +34 -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 +62 -0
- package/package.json +5 -2
- 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/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 +76 -0
|
@@ -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('&', '&')
|
|
147
|
+
.replaceAll('<', '<')
|
|
148
|
+
.replaceAll('>', '>')
|
|
149
|
+
.replaceAll('"', '"')
|
|
150
|
+
.replaceAll("'", ''');
|
|
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('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll(''', "'").replaceAll('&', '&')));
|
|
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
|
+
}
|