sap-ai-dev-toolkit 0.9.9917 → 0.9.9920
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 +32 -18
- package/.github/agents/sap-integration-developer.agent.md +28 -12
- package/.github/skills/integration-suite-delivery/SKILL.md +94 -37
- package/.github/skills/sap-integration-architect/SKILL.md +49 -25
- package/README.md +73 -14
- 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 +97 -57
- package/package.json +1 -1
- package/scripts/check-live-integration.mjs +151 -0
- package/scripts/install-local.mjs +11 -4
- package/scripts/run-live-integration.mjs +52 -60
- package/src/apim-bundle.mjs +416 -13
- package/src/apim-cleanup-tools.mjs +145 -0
- package/src/apim-content.mjs +157 -0
- package/src/apim-delete-tools.mjs +139 -0
- package/src/apim-documentation.mjs +162 -0
- package/src/apim-inspection.mjs +141 -0
- package/src/apim-monitoring.mjs +140 -0
- package/src/apim-policy-steps.mjs +31 -0
- package/src/apim-runtime.mjs +262 -0
- package/src/apim-tools.mjs +202 -109
- package/src/cf-integration.mjs +46 -7
- package/src/cpi-async-monitoring.mjs +177 -0
- package/src/cpi-content.mjs +92 -0
- package/src/cpi-monitoring.mjs +225 -0
- package/src/cpi-operational.mjs +129 -0
- package/src/cpi-release.mjs +273 -0
- package/src/cpi-security.mjs +48 -0
- package/src/cpi-tools.mjs +184 -319
- package/src/devhub-retirement.mjs +345 -0
- package/src/devhub-tools.mjs +177 -0
- package/src/edge-monitoring.mjs +101 -0
- package/src/iflow-bundle.mjs +295 -7
- package/src/integration-alerts.mjs +248 -0
- package/src/integration-analysis.mjs +488 -0
- package/src/integration-capabilities.mjs +181 -0
- package/src/integration-collections.mjs +203 -0
- package/src/integration-common.mjs +140 -35
- package/src/integration-config.mjs +20 -14
- package/src/integration-credentials.mjs +16 -10
- package/src/integration-http.mjs +255 -67
- package/src/integration-messaging.mjs +118 -0
- package/src/integration-optional-config.mjs +122 -0
- package/src/integration-optional-tools.mjs +153 -0
- package/src/integration-release-store.mjs +410 -0
- package/src/integration-server.mjs +34 -16
- package/src/integration-state.mjs +31 -0
- package/src/integration-tools.mjs +44 -8
- package/src/integration-transport.mjs +184 -0
- package/src/integration-validation.mjs +26 -0
- package/src/integration-verify.mjs +143 -62
- package/src/open-connectors-tools.mjs +143 -0
- package/src/setup.mjs +66 -44
- package/tools.md +193 -26
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
// Private immutable content backups. Archives and configuration values never enter tool results.
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { constants } from 'node:fs';
|
|
5
|
+
import { open, mkdir, chmod, lstat, readdir, rename, rm } from 'node:fs/promises';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { integrationCredentialsPath } from './integration-credentials.mjs';
|
|
8
|
+
import { createCollectionPager, cursorSchema } from './integration-collections.mjs';
|
|
9
|
+
import { READ_ONLY_ANNOTATIONS, CREATE_ANNOTATIONS, OVERWRITE_ANNOTATIONS, requireConfirmation } from './integration-common.mjs';
|
|
10
|
+
import { IntegrationError } from './integration-http.mjs';
|
|
11
|
+
import { readCpiContent, writeCpiConfigurations, CPI_BASE, cpiArtifactPath } from './cpi-content.mjs';
|
|
12
|
+
import { readApiProxy, exportApiProxyArchive, importApiProxyBundle } from './apim-content.mjs';
|
|
13
|
+
import { bundleDigest, readBundle, strFromU8, parseProperties } from './iflow-bundle.mjs';
|
|
14
|
+
import { apiProxyLayout, xmlLocate, xmlChildren, xmlText } from './apim-bundle.mjs';
|
|
15
|
+
import { canonicalJson, canonicalDigest, stateDigest } from './integration-state.mjs';
|
|
16
|
+
|
|
17
|
+
const MAX_ARCHIVE = 20 * 1024 * 1024;
|
|
18
|
+
const MAX_SNAPSHOT = 100 * 1024 * 1024;
|
|
19
|
+
const MAX_MANIFEST = 256 * 1024;
|
|
20
|
+
const SOURCE_RECORDS = 1000;
|
|
21
|
+
const DIGEST = /^[a-f0-9]{64}$/;
|
|
22
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
|
|
23
|
+
const id = z.string().regex(/^[A-Za-z][A-Za-z0-9_.-]{0,99}$/);
|
|
24
|
+
const proxyId = z.string().regex(/^[A-Za-z][A-Za-z0-9_-]{0,99}$/);
|
|
25
|
+
const version = z.string().max(100).regex(/^(?:active|\d+\.\d+\.\d+)$/);
|
|
26
|
+
const targetSchema = z.discriminatedUnion('kind', [
|
|
27
|
+
z.object({ kind: z.literal('cpi_iflow'), id, version: version.default('active') }).strict(),
|
|
28
|
+
z.object({ kind: z.literal('apim_proxy'), id: proxyId }).strict()
|
|
29
|
+
]);
|
|
30
|
+
const snapshotSchema = z.object({ targets: z.array(targetSchema).min(1).max(20), dry_run: z.boolean().default(true), confirmation: z.string().max(200).optional() }).strict();
|
|
31
|
+
const restoreSchema = z.object({ snapshot_id: z.string().regex(UUID), expected_current_digests: z.record(z.string().regex(DIGEST)).optional(), dry_run: z.boolean().default(true), confirmation: z.string().max(200).optional() }).strict();
|
|
32
|
+
const listSchema = z.object({ top: z.number().int().min(1).max(200).default(100), cursor: cursorSchema }).strict();
|
|
33
|
+
const configSchema = z.array(z.object({ ParameterKey: z.string().max(1000), ParameterValue: z.string(), DataType: z.string().max(1000) }).strict()).max(2000);
|
|
34
|
+
const utf8Order = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b));
|
|
35
|
+
const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
36
|
+
const targetKey = target => `${target.kind}:${target.id}`;
|
|
37
|
+
const invalid = () => { throw new IntegrationError('Snapshot record is invalid, incomplete, or has failed integrity verification'); };
|
|
38
|
+
function parse(schema, args) {
|
|
39
|
+
const parsed = schema.safeParse(args);
|
|
40
|
+
if (!parsed.success) throw new IntegrationError('Invalid integration release input; check target kinds, selectors, booleans and digest fields');
|
|
41
|
+
return parsed.data;
|
|
42
|
+
}
|
|
43
|
+
function selectedTargets(targets) {
|
|
44
|
+
const sorted = [...targets].sort((a, b) => utf8Order(targetKey(a), targetKey(b)));
|
|
45
|
+
if (new Set(sorted.map(targetKey)).size !== sorted.length) throw new IntegrationError('Snapshot targets must have unique kind and identity');
|
|
46
|
+
return sorted;
|
|
47
|
+
}
|
|
48
|
+
function originsFor(targets, clients) {
|
|
49
|
+
const result = {};
|
|
50
|
+
for (const target of targets) {
|
|
51
|
+
const side = target.kind === 'cpi_iflow' ? 'cpi' : 'apim';
|
|
52
|
+
if (!clients[side] || typeof clients[side].request !== 'function') throw new IntegrationError(`The ${side.toUpperCase()} side is not configured`);
|
|
53
|
+
let url;
|
|
54
|
+
try { url = new URL(clients[side].origin); } catch { throw new IntegrationError('Release client has no pinned tenant origin'); }
|
|
55
|
+
if (url.username || url.password || url.search || url.hash || url.pathname !== '/' || url.hostname.length > 253 || !['https:', 'http:'].includes(url.protocol)) throw new IntegrationError('Release client has an invalid tenant origin');
|
|
56
|
+
result[side] = url.origin;
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
const hostsFor = origins => Object.fromEntries(Object.entries(origins).map(([side, origin]) => [side, new URL(origin).host]));
|
|
61
|
+
function configurationIdentity(configurations) {
|
|
62
|
+
const parsed = parse(configSchema, configurations);
|
|
63
|
+
if (new Set(parsed.map(row => row.ParameterKey)).size !== parsed.length) invalid();
|
|
64
|
+
return parsed.sort((a, b) => utf8Order(a.ParameterKey, b.ParameterKey));
|
|
65
|
+
}
|
|
66
|
+
function safeFailure(error) {
|
|
67
|
+
const status = Number(error?.status);
|
|
68
|
+
return { result: status === 401 ? 'unauthenticated' : status === 403 ? 'forbidden' : [404, 501].includes(status) ? 'unavailable' : 'error',
|
|
69
|
+
...(Number.isInteger(status) && status >= 100 && status <= 599 ? { status } : {}) };
|
|
70
|
+
}
|
|
71
|
+
function namesSummary(names, prefix = 'changed_parameter') {
|
|
72
|
+
const sorted = [...new Set(names)].sort(utf8Order);
|
|
73
|
+
return { [`${prefix}_names`]: sorted.slice(0, 20).map(name => name.slice(0, 100)), [`${prefix}_count`]: sorted.length,
|
|
74
|
+
[`${prefix}_names_truncated`]: sorted.length > 20 || sorted.some(name => name.length > 100) };
|
|
75
|
+
}
|
|
76
|
+
function parameterDefaults(files) {
|
|
77
|
+
const bytes = files.get('src/main/resources/parameters.prop');
|
|
78
|
+
return bytes ? parseProperties(strFromU8(bytes)) : {};
|
|
79
|
+
}
|
|
80
|
+
function changedParameters(saved, current) {
|
|
81
|
+
const configured = content => new Map(content.configurations.map(row => [row.ParameterKey, [row.ParameterValue, row.DataType]]));
|
|
82
|
+
const defaults = content => new Map(Object.entries(content.parameter_defaults));
|
|
83
|
+
const before = configured(current), after = configured(saved);
|
|
84
|
+
const beforeDefaults = defaults(current), afterDefaults = defaults(saved);
|
|
85
|
+
return [...new Set([...before.keys(), ...after.keys(), ...beforeDefaults.keys(), ...afterDefaults.keys()])]
|
|
86
|
+
.filter(name => canonicalJson(before.get(name)) !== canonicalJson(after.get(name)) || beforeDefaults.get(name) !== afterDefaults.get(name));
|
|
87
|
+
}
|
|
88
|
+
async function regularFile(path, maxBytes) {
|
|
89
|
+
const stat = await lstat(path);
|
|
90
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > maxBytes || (stat.mode & 0o077)) invalid();
|
|
91
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
92
|
+
try {
|
|
93
|
+
const opened = await handle.stat();
|
|
94
|
+
if (opened.dev !== stat.dev || opened.ino !== stat.ino || opened.size !== stat.size || !opened.isFile()) invalid();
|
|
95
|
+
const bytes = Buffer.alloc(opened.size);
|
|
96
|
+
let offset = 0;
|
|
97
|
+
while (offset < bytes.length) {
|
|
98
|
+
const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, offset);
|
|
99
|
+
if (!bytesRead) invalid();
|
|
100
|
+
offset += bytesRead;
|
|
101
|
+
}
|
|
102
|
+
const after = await handle.stat();
|
|
103
|
+
if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs) invalid();
|
|
104
|
+
return bytes;
|
|
105
|
+
} finally { await handle.close(); }
|
|
106
|
+
}
|
|
107
|
+
async function privateDirectory(path, create = false) {
|
|
108
|
+
if (create) await mkdir(path, { recursive: true, mode: 0o700 });
|
|
109
|
+
const stat = await lstat(path);
|
|
110
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) invalid();
|
|
111
|
+
if (create) await chmod(path, 0o700);
|
|
112
|
+
else if (stat.mode & 0o077) invalid();
|
|
113
|
+
}
|
|
114
|
+
async function exclusiveFile(path, bytes) {
|
|
115
|
+
const handle = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
116
|
+
try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); }
|
|
117
|
+
}
|
|
118
|
+
function publicTarget(target) {
|
|
119
|
+
return { kind: target.kind, id: target.id, ...(target.kind === 'cpi_iflow' ? { selector: target.selector, resolved_version: target.resolved_version } : {}),
|
|
120
|
+
archive_bytes: target.archive_bytes, archive_sha256: target.archive_sha256, bundle_sha256: target.bundle_sha256, state_sha256: target.state_sha256,
|
|
121
|
+
...(target.kind === 'cpi_iflow' ? { configuration_bytes: target.configuration_bytes, configuration_sha256: target.configuration_sha256 } : {}) };
|
|
122
|
+
}
|
|
123
|
+
function publicSnapshot(manifest) {
|
|
124
|
+
return { snapshot_id: manifest.snapshot_id, created_at: manifest.created_at, environment: manifest.environment, hosts: hostsFor(manifest.origins),
|
|
125
|
+
snapshot_sha256: canonicalDigest(manifest), total_bytes: manifest.total_bytes, targets: manifest.targets.map(publicTarget) };
|
|
126
|
+
}
|
|
127
|
+
function assertArchiveIdentity(files, target) {
|
|
128
|
+
if (target.kind === 'cpi_iflow') {
|
|
129
|
+
const manifest = files.get('META-INF/MANIFEST.MF');
|
|
130
|
+
if (!manifest) invalid();
|
|
131
|
+
const lines = strFromU8(manifest).replace(/\r\n/g, '\n').replace(/\n /g, '').split('\n');
|
|
132
|
+
const identities = lines.filter(line => line.startsWith('Bundle-SymbolicName:')).map(line => line.slice('Bundle-SymbolicName:'.length).trim().split(';')[0].trim());
|
|
133
|
+
if (identities.length !== 1 || identities[0] !== target.id) invalid();
|
|
134
|
+
} else {
|
|
135
|
+
const { root } = apiProxyLayout(files, target.id);
|
|
136
|
+
const roots = [...files.keys()].filter(path => /(?:^|\/)APIProxy\/[^/]+\.xml$/.test(path));
|
|
137
|
+
if (roots.length !== 1 || root !== roots[0]) invalid();
|
|
138
|
+
const xml = strFromU8(files.get(root));
|
|
139
|
+
const element = xmlLocate(xml, []);
|
|
140
|
+
const names = xmlChildren(xml, element).filter(child => child.name.split(':').at(-1) === 'name');
|
|
141
|
+
if (element.name.split(':').at(-1) !== 'APIProxy' || names.length !== 1 || xmlText(xml, names[0]) !== target.id) invalid();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async function readTargetRaw(target, clients) {
|
|
145
|
+
if (target.kind === 'cpi_iflow') {
|
|
146
|
+
const content = await readCpiContent(clients.cpi, target.id, target.version ?? 'active');
|
|
147
|
+
assertArchiveIdentity(content.files, target);
|
|
148
|
+
if (typeof content.artifact.Name !== 'string' || content.artifact.Name.length > 1000 || typeof content.resolved_version !== 'string' || content.resolved_version.length > 100 || !/^\d+\.\d+\.\d+$/.test(content.resolved_version) || target.version && target.version !== 'active' && content.resolved_version !== target.version) throw new IntegrationError('CPI snapshot identity or selected version changed');
|
|
149
|
+
const { files, ...original } = content;
|
|
150
|
+
return { ...original, parameter_defaults: parameterDefaults(files), configurations: configurationIdentity(content.configurations), name: content.artifact.Name };
|
|
151
|
+
}
|
|
152
|
+
const artifact = await readApiProxy(clients.apim, target.id);
|
|
153
|
+
if (!artifact || artifact.name !== target.id) throw new IntegrationError('API proxy identity is unavailable');
|
|
154
|
+
const bytes = await exportApiProxyArchive(clients.apim, target.id);
|
|
155
|
+
if (bytes === null) throw new IntegrationError('API proxy export is unavailable');
|
|
156
|
+
const files = readBundle(bytes);
|
|
157
|
+
assertArchiveIdentity(files, target);
|
|
158
|
+
const bundle_sha256 = bundleDigest(files);
|
|
159
|
+
return { bytes, parameter_defaults: {}, configurations: [], name: target.id, bundle_sha256, state_sha256: stateDigest(bundle_sha256) };
|
|
160
|
+
}
|
|
161
|
+
async function readTarget(target, clients) {
|
|
162
|
+
try { return await readTargetRaw(target, clients); }
|
|
163
|
+
catch (error) {
|
|
164
|
+
const status = safeFailure(error).status;
|
|
165
|
+
throw new IntegrationError(`Integration content could not be read completely${status ? ` (HTTP ${status})` : ''}; private diagnostic details withheld`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async function loadSnapshotRaw(root, snapshotId) {
|
|
169
|
+
await privateDirectory(dirname(root));
|
|
170
|
+
await privateDirectory(root);
|
|
171
|
+
const directory = join(root, snapshotId);
|
|
172
|
+
await privateDirectory(directory);
|
|
173
|
+
const manifestBytes = await regularFile(join(directory, 'manifest.json'), MAX_MANIFEST);
|
|
174
|
+
let manifest;
|
|
175
|
+
try { manifest = JSON.parse(manifestBytes.toString('utf8')); } catch { invalid(); }
|
|
176
|
+
if (!manifest || manifest.schema_version !== 1 || manifest.snapshot_id !== snapshotId || !UUID.test(manifest.snapshot_id) || typeof manifest.environment !== 'string' || manifest.environment.length > 100 || typeof manifest.created_at !== 'string' || !Number.isFinite(Date.parse(manifest.created_at)) || !Array.isArray(manifest.targets) || !manifest.targets.length || manifest.targets.length > 20 || !manifest.origins || typeof manifest.origins !== 'object') invalid();
|
|
177
|
+
const { manifest_sha256, ...manifestContent } = manifest;
|
|
178
|
+
if (!DIGEST.test(manifest_sha256) || manifest_sha256 !== canonicalDigest(manifestContent)) invalid();
|
|
179
|
+
const identities = manifest.targets.map(target => {
|
|
180
|
+
if (target.kind === 'cpi_iflow' && typeof target.selector === 'string') return { kind: target.kind, id: target.id, version: target.selector };
|
|
181
|
+
if (target.kind === 'apim_proxy' && target.selector === undefined && target.resolved_version === undefined) return { kind: target.kind, id: target.id };
|
|
182
|
+
invalid();
|
|
183
|
+
});
|
|
184
|
+
const parsed = selectedTargets(parse(z.array(targetSchema).min(1).max(20), identities));
|
|
185
|
+
if (canonicalJson(parsed) !== canonicalJson(identities)) invalid();
|
|
186
|
+
const requiredSides = [...new Set(parsed.map(target => target.kind === 'cpi_iflow' ? 'cpi' : 'apim'))].sort();
|
|
187
|
+
if (canonicalJson(Object.keys(manifest.origins).sort()) !== canonicalJson(requiredSides)) invalid();
|
|
188
|
+
for (const origin of Object.values(manifest.origins)) {
|
|
189
|
+
let url;
|
|
190
|
+
try { url = new URL(origin); } catch { invalid(); }
|
|
191
|
+
if (origin !== url.origin || !['https:', 'http:'].includes(url.protocol)) invalid();
|
|
192
|
+
}
|
|
193
|
+
const allowedFiles = ['manifest.json'];
|
|
194
|
+
const contents = [];
|
|
195
|
+
let total = 0;
|
|
196
|
+
for (let index = 0; index < manifest.targets.length; index++) {
|
|
197
|
+
const target = manifest.targets[index];
|
|
198
|
+
const stem = String(index).padStart(4, '0');
|
|
199
|
+
if (target.archive_file !== `${stem}.zip` || typeof target.name !== 'string' || target.name.length > 1000 || !DIGEST.test(target.archive_sha256) || !DIGEST.test(target.bundle_sha256) || !DIGEST.test(target.state_sha256) || !Number.isSafeInteger(target.archive_bytes) || target.archive_bytes < 1 || target.archive_bytes > MAX_ARCHIVE) invalid();
|
|
200
|
+
allowedFiles.push(target.archive_file);
|
|
201
|
+
const bytes = await regularFile(join(directory, target.archive_file), MAX_ARCHIVE);
|
|
202
|
+
total += bytes.byteLength;
|
|
203
|
+
if (bytes.byteLength !== target.archive_bytes || sha256(bytes) !== target.archive_sha256 || total > MAX_SNAPSHOT) invalid();
|
|
204
|
+
const files = readBundle(bytes);
|
|
205
|
+
assertArchiveIdentity(files, target);
|
|
206
|
+
if (bundleDigest(files) !== target.bundle_sha256) invalid();
|
|
207
|
+
let configurations = [];
|
|
208
|
+
if (target.kind === 'cpi_iflow') {
|
|
209
|
+
if (typeof target.resolved_version !== 'string' || target.resolved_version.length > 100 || !/^\d+\.\d+\.\d+$/.test(target.resolved_version) || target.selector !== 'active' && target.selector !== target.resolved_version || target.configuration_file !== `${stem}-config.json` || !DIGEST.test(target.configuration_sha256) || !Number.isSafeInteger(target.configuration_bytes) || target.configuration_bytes < 2 || target.configuration_bytes > MAX_SNAPSHOT - total) invalid();
|
|
210
|
+
allowedFiles.push(target.configuration_file);
|
|
211
|
+
const configurationBytes = await regularFile(join(directory, target.configuration_file), MAX_SNAPSHOT - total);
|
|
212
|
+
total += configurationBytes.byteLength;
|
|
213
|
+
if (configurationBytes.byteLength !== target.configuration_bytes || sha256(configurationBytes) !== target.configuration_sha256) invalid();
|
|
214
|
+
try { configurations = configurationIdentity(JSON.parse(configurationBytes.toString('utf8'))); } catch { invalid(); }
|
|
215
|
+
if (canonicalDigest(configurations) !== target.configuration_digest) invalid();
|
|
216
|
+
} else if (target.name !== target.id || ['configuration_file', 'configuration_bytes', 'configuration_sha256', 'configuration_digest'].some(key => target[key] !== undefined)) invalid();
|
|
217
|
+
if (stateDigest(target.bundle_sha256, configurations) !== target.state_sha256) invalid();
|
|
218
|
+
contents.push({ bytes, configurations, parameter_defaults: target.kind === 'cpi_iflow' ? parameterDefaults(files) : {} });
|
|
219
|
+
}
|
|
220
|
+
if (total !== manifest.total_bytes || total + manifestBytes.byteLength > MAX_SNAPSHOT || canonicalJson((await readdir(directory)).sort()) !== canonicalJson(allowedFiles.sort())) invalid();
|
|
221
|
+
return { manifest, contents, digest: canonicalDigest(manifest) };
|
|
222
|
+
}
|
|
223
|
+
async function loadSnapshot(root, snapshotId) {
|
|
224
|
+
try { return await loadSnapshotRaw(root, snapshotId); }
|
|
225
|
+
catch { invalid(); }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Reuse full private archive/configuration verification; expose only matching state identities. */
|
|
229
|
+
export async function snapshotEvidenceForTargets(clients, targets, { env = process.env, deadline = Date.now() + 30000 } = {}) {
|
|
230
|
+
const root = join(dirname(integrationCredentialsPath(env)), 'releases');
|
|
231
|
+
const result = { result: 'ok', complete: true, inspected_records: 0, items: [], findings: [] };
|
|
232
|
+
let entries;
|
|
233
|
+
try {
|
|
234
|
+
await privateDirectory(dirname(root));
|
|
235
|
+
await privateDirectory(root);
|
|
236
|
+
entries = (await readdir(root)).filter(name => !name.startsWith('.pending-')).sort(utf8Order);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (error.code === 'ENOENT') return result;
|
|
239
|
+
return { ...result, result: 'error', complete: false, findings: [{ reason: 'private_snapshot_store_unreadable' }] };
|
|
240
|
+
}
|
|
241
|
+
for (const name of entries.slice(0, 100)) {
|
|
242
|
+
if (Date.now() >= deadline) { result.complete = false; result.stop_reason = 'deadline'; break; }
|
|
243
|
+
result.inspected_records++;
|
|
244
|
+
if (!UUID.test(name)) { result.findings.push({ reason: 'unrecognized_store_record' }); continue; }
|
|
245
|
+
try {
|
|
246
|
+
const { manifest } = await loadSnapshot(root, name);
|
|
247
|
+
const matches = manifest.targets.filter(saved => targets.some(target => {
|
|
248
|
+
const side = saved.kind === 'cpi_iflow' ? 'cpi' : 'apim';
|
|
249
|
+
return target.kind === saved.kind && target.id === saved.id && target.state_sha256 === saved.state_sha256 &&
|
|
250
|
+
manifest.origins[side] === clients[side]?.origin;
|
|
251
|
+
}));
|
|
252
|
+
if (matches.length) result.items.push({ snapshot_id: manifest.snapshot_id, created_at: manifest.created_at,
|
|
253
|
+
snapshot_sha256: canonicalDigest(manifest), targets: matches.map(target => ({ kind: target.kind, id: target.id, state_sha256: target.state_sha256 })) });
|
|
254
|
+
} catch { result.findings.push({ snapshot_id: name, reason: 'invalid_snapshot_record' }); }
|
|
255
|
+
if (Buffer.byteLength(JSON.stringify(result, null, 2)) > 24 * 1024) {
|
|
256
|
+
result.items.pop(); result.complete = false; result.stop_reason = 'output_limit'; break;
|
|
257
|
+
}
|
|
258
|
+
if (result.items.length >= 20) { result.complete = false; result.stop_reason = 'source_limit'; break; }
|
|
259
|
+
}
|
|
260
|
+
if (entries.length > result.inspected_records && !result.stop_reason) { result.complete = false; result.stop_reason = 'source_limit'; }
|
|
261
|
+
return result;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function registerIntegrationReleaseTools(register, { cpi, apim, summary = {}, env = process.env, readBackDelayMs = 5000 } = {}) {
|
|
265
|
+
const clients = { cpi, apim };
|
|
266
|
+
const root = join(dirname(integrationCredentialsPath(env)), 'releases');
|
|
267
|
+
const environment = typeof summary.environment === 'string' && summary.environment.length <= 100 ? summary.environment : 'default';
|
|
268
|
+
const localClient = { origin: 'https://integration-snapshot.invalid', request: async () => listingSource() };
|
|
269
|
+
const pager = createCollectionPager(localClient, { root: '/releases' });
|
|
270
|
+
async function listingSource() {
|
|
271
|
+
try { await privateDirectory(dirname(root)); await privateDirectory(root); }
|
|
272
|
+
catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
273
|
+
const entries = (await readdir(root)).filter(name => !name.startsWith('.pending-')).sort(utf8Order);
|
|
274
|
+
const items = [];
|
|
275
|
+
for (const name of entries.slice(0, SOURCE_RECORDS)) {
|
|
276
|
+
if (!UUID.test(name)) { items.push({ valid: false, finding: 'unrecognized_store_record' }); continue; }
|
|
277
|
+
try { items.push({ valid: true, ...publicSnapshot((await loadSnapshot(root, name)).manifest) }); }
|
|
278
|
+
catch { items.push({ snapshot_id: name, valid: false, finding: 'invalid_snapshot_record' }); }
|
|
279
|
+
}
|
|
280
|
+
// A sentinel preserves explicit source truncation without pretending to traverse unbounded storage.
|
|
281
|
+
if (entries.length > SOURCE_RECORDS) items.push({ valid: false, finding: 'store_source_limit', source_complete: false });
|
|
282
|
+
return items;
|
|
283
|
+
}
|
|
284
|
+
register('integration_snapshot_content', 'Snapshot integration content',
|
|
285
|
+
'Preview explicit tenant-bound content identities, then privately save immutable original ZIPs and exact CPI configuration. Execution requires the preview confirmation; no deployment occurs.', snapshotSchema, CREATE_ANNOTATIONS,
|
|
286
|
+
async input => {
|
|
287
|
+
const args = parse(snapshotSchema, input);
|
|
288
|
+
const targets = selectedTargets(args.targets);
|
|
289
|
+
const origins = originsFor(targets, clients);
|
|
290
|
+
const required_confirmation = `SNAPSHOT INTEGRATION CONTENT ${canonicalDigest({ targets, origins })}`;
|
|
291
|
+
if (args.dry_run) return { dry_run: true, environment, hosts: hostsFor(origins), targets, required_confirmation };
|
|
292
|
+
requireConfirmation(args.confirmation, required_confirmation);
|
|
293
|
+
const snapshotId = randomUUID();
|
|
294
|
+
const manifest = { schema_version: 1, snapshot_id: snapshotId, created_at: new Date().toISOString(), environment, origins, targets: [], total_bytes: 0 };
|
|
295
|
+
const contents = [];
|
|
296
|
+
for (let index = 0; index < targets.length; index++) {
|
|
297
|
+
const target = targets[index];
|
|
298
|
+
const content = await readTarget(target, clients);
|
|
299
|
+
if (content.bytes.byteLength > MAX_ARCHIVE) throw new IntegrationError('Snapshot archive exceeds the 20 MiB compressed limit');
|
|
300
|
+
const stem = String(index).padStart(4, '0');
|
|
301
|
+
const record = { kind: target.kind, id: target.id, name: content.name, archive_file: `${stem}.zip`, archive_bytes: content.bytes.byteLength,
|
|
302
|
+
archive_sha256: sha256(content.bytes), bundle_sha256: content.bundle_sha256, state_sha256: content.state_sha256 };
|
|
303
|
+
const members = [[record.archive_file, content.bytes]];
|
|
304
|
+
if (target.kind === 'cpi_iflow') {
|
|
305
|
+
const configBytes = Buffer.from(canonicalJson(content.configurations));
|
|
306
|
+
Object.assign(record, { selector: target.version, resolved_version: content.resolved_version, configuration_file: `${stem}-config.json`,
|
|
307
|
+
configuration_bytes: configBytes.byteLength, configuration_sha256: sha256(configBytes), configuration_digest: canonicalDigest(content.configurations) });
|
|
308
|
+
members.push([record.configuration_file, configBytes]);
|
|
309
|
+
}
|
|
310
|
+
manifest.targets.push(record);
|
|
311
|
+
for (const member of members) { manifest.total_bytes += member[1].byteLength; contents.push(member); }
|
|
312
|
+
if (manifest.total_bytes > MAX_SNAPSHOT) throw new IntegrationError('Snapshot exceeds the 100 MiB total limit');
|
|
313
|
+
}
|
|
314
|
+
manifest.manifest_sha256 = canonicalDigest(manifest);
|
|
315
|
+
const manifestBytes = Buffer.from(canonicalJson(manifest));
|
|
316
|
+
if (manifestBytes.byteLength > MAX_MANIFEST || manifest.total_bytes + manifestBytes.byteLength > MAX_SNAPSHOT) throw new IntegrationError('Snapshot exceeds the manifest or 100 MiB total limit');
|
|
317
|
+
await privateDirectory(dirname(root), true);
|
|
318
|
+
await privateDirectory(root, true);
|
|
319
|
+
const staging = join(root, `.pending-${snapshotId}`);
|
|
320
|
+
await mkdir(staging, { mode: 0o700 });
|
|
321
|
+
try {
|
|
322
|
+
for (const [filename, bytes] of contents) await exclusiveFile(join(staging, filename), bytes);
|
|
323
|
+
await exclusiveFile(join(staging, 'manifest.json'), manifestBytes);
|
|
324
|
+
await rename(staging, join(root, snapshotId));
|
|
325
|
+
} catch (error) { await rm(staging, { recursive: true, force: true }); throw error; }
|
|
326
|
+
return { saved: true, ...publicSnapshot(manifest) };
|
|
327
|
+
});
|
|
328
|
+
register('integration_list_snapshots', 'List private integration snapshots',
|
|
329
|
+
'List safe snapshot identities and integrity findings with runtime-signed bounded continuation. Archives, source and configured values remain private.', listSchema, READ_ONLY_ANNOTATIONS,
|
|
330
|
+
async input => {
|
|
331
|
+
const args = parse(listSchema, input);
|
|
332
|
+
const result = await pager.page({ path: '/snapshots', mode: 'local', top: args.top, cursor: args.cursor, scope: canonicalDigest({ root, environment, origins: { cpi: cpi?.origin ?? null, apim: apim?.origin ?? null } }) });
|
|
333
|
+
if (result.items.some(item => item.source_complete === false)) return { ...result, complete: false, stop_reason: 'source_limit' };
|
|
334
|
+
return result;
|
|
335
|
+
});
|
|
336
|
+
register('integration_restore_snapshot', 'Restore integration snapshot',
|
|
337
|
+
'Preview configuration-aware current digests and all effects. Execution requires exact target digests and tenant-bound confirmation. CPI replaces only the active draft; APIM import deploys a new revision. Stops on partial failure, with no atomicity, compensation or transaction rollback.', restoreSchema, OVERWRITE_ANNOTATIONS,
|
|
338
|
+
async input => {
|
|
339
|
+
const args = parse(restoreSchema, input);
|
|
340
|
+
const saved = await loadSnapshot(root, args.snapshot_id);
|
|
341
|
+
const { manifest } = saved;
|
|
342
|
+
const targets = manifest.targets;
|
|
343
|
+
const origins = originsFor(targets, clients);
|
|
344
|
+
if (canonicalJson(origins) !== canonicalJson(manifest.origins)) throw new IntegrationError('Snapshot restore is restricted to the same bound tenant origins');
|
|
345
|
+
const expected = args.expected_current_digests;
|
|
346
|
+
if (!args.dry_run && (!expected || canonicalJson(Object.keys(expected).sort()) !== canonicalJson(targets.map(targetKey).sort()))) throw new IntegrationError('Execution requires exactly one expected_current_digests entry for every snapshot target');
|
|
347
|
+
const confirmationFor = digests => `RESTORE INTEGRATION SNAPSHOT ${canonicalDigest({ snapshot_sha256: saved.digest, expected_current_digests: digests, origins })}`;
|
|
348
|
+
if (!args.dry_run) requireConfirmation(args.confirmation, confirmationFor(expected));
|
|
349
|
+
const current = [];
|
|
350
|
+
// Entire preflight completes before the first mutation, then each target is checked again immediately before writing.
|
|
351
|
+
for (const target of targets) {
|
|
352
|
+
const { bytes, ...evidence } = await readTarget({ kind: target.kind, id: target.id }, clients);
|
|
353
|
+
current.push(evidence);
|
|
354
|
+
}
|
|
355
|
+
const currentDigests = Object.fromEntries(targets.map((target, index) => [targetKey(target), current[index].state_sha256]));
|
|
356
|
+
const previews = targets.map((target, index) => ({ ...publicTarget(target), current_state_sha256: current[index].state_sha256,
|
|
357
|
+
...namesSummary(changedParameters(saved.contents[index], current[index])),
|
|
358
|
+
effects: target.kind === 'cpi_iflow' ? { active_draft_replacement: true, retained_source_version_unchanged: true, deployment: false } : { draft_replacement: true, imports_new_revision: true, deployment: true } }));
|
|
359
|
+
const base = { snapshot_id: args.snapshot_id, snapshot_sha256: saved.digest, environment, hosts: hostsFor(origins), atomic: false, compensation: false,
|
|
360
|
+
does_not_reverse: ['processed_messages', 'datastores', 'security_material', 'issued_tokens', 'receiver_transactions'] };
|
|
361
|
+
if (args.dry_run) return { ...base, dry_run: true, targets: previews, expected_current_digests: currentDigests, required_confirmation: confirmationFor(currentDigests) };
|
|
362
|
+
if (canonicalJson(currentDigests) !== canonicalJson(expected)) return { ...base, outcome: 'blocked', reason: 'current_state_mismatch', targets: previews, progress: [] };
|
|
363
|
+
const progress = [];
|
|
364
|
+
for (let index = 0; index < targets.length; index++) {
|
|
365
|
+
const target = targets[index];
|
|
366
|
+
const content = saved.contents[index];
|
|
367
|
+
const item = { kind: target.kind, id: target.id, accepted: false, content_verified: false, configuration_verified: false, outcome: 'failed' };
|
|
368
|
+
try {
|
|
369
|
+
const fresh = await readTarget({ kind: target.kind, id: target.id }, clients);
|
|
370
|
+
if (fresh.state_sha256 !== expected[targetKey(target)]) { item.reason = 'current_state_mismatch'; item.outcome = 'blocked'; progress.push(item); break; }
|
|
371
|
+
if (target.kind === 'cpi_iflow') {
|
|
372
|
+
await cpi.request('PUT', `${CPI_BASE}${cpiArtifactPath(target.id)}`, { json: { Name: target.name, ArtifactContent: Buffer.from(content.bytes).toString('base64') }, expect: [200, 202, 204], responseType: 'none', accept: '*/*' });
|
|
373
|
+
item.accepted = true;
|
|
374
|
+
item.draft_replaced = true;
|
|
375
|
+
item.deployment = false;
|
|
376
|
+
const configuration = await writeCpiConfigurations(cpi, target.id, content.configurations);
|
|
377
|
+
Object.assign(item, namesSummary(configuration.updated ?? [], 'updated_parameter'));
|
|
378
|
+
if (configuration.failed) {
|
|
379
|
+
item.outcome = 'partial'; item.reason = 'configuration_restore_failed'; item.failure = safeFailure(configuration.failed);
|
|
380
|
+
item.failed_parameter_name = typeof configuration.failed.key === 'string' ? configuration.failed.key.slice(0, 100) : null;
|
|
381
|
+
progress.push(item); break;
|
|
382
|
+
}
|
|
383
|
+
} else {
|
|
384
|
+
const imported = await importApiProxyBundle(apim, target.id, content.bytes, { readBackDelayMs });
|
|
385
|
+
item.accepted = imported.accepted === true;
|
|
386
|
+
item.deployment = true;
|
|
387
|
+
item.imports_new_revision = true;
|
|
388
|
+
item.runtime_verified = imported.runtime_verified === true;
|
|
389
|
+
if (!item.accepted) { item.reason = 'import_not_accepted'; progress.push(item); break; }
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
const restored = await readTarget({ kind: target.kind, id: target.id }, clients);
|
|
393
|
+
item.content_verified = restored.bundle_sha256 === target.bundle_sha256 && restored.name === target.name;
|
|
394
|
+
item.configuration_verified = stateDigest(target.bundle_sha256, restored.configurations) === target.state_sha256;
|
|
395
|
+
item.observed_state_sha256 = restored.state_sha256;
|
|
396
|
+
item.outcome = item.content_verified && item.configuration_verified ? 'restored' : 'unverified';
|
|
397
|
+
if (item.outcome === 'unverified') item.reason = 'restored_state_mismatch_or_extra_configuration';
|
|
398
|
+
} catch (error) { item.outcome = 'unverified'; item.reason = 'read_back_failed'; item.failure = safeFailure(error); }
|
|
399
|
+
} catch (error) {
|
|
400
|
+
// Import helpers preserve acceptance when post-import verification fails; never retry a mutation here.
|
|
401
|
+
item.accepted ||= error?.accepted === true;
|
|
402
|
+
item.outcome = item.accepted ? 'partial' : 'failed'; item.failure = safeFailure(error);
|
|
403
|
+
}
|
|
404
|
+
progress.push(item);
|
|
405
|
+
if (item.outcome !== 'restored') break;
|
|
406
|
+
}
|
|
407
|
+
return { ...base, outcome: progress.length === targets.length && progress.every(item => item.outcome === 'restored') ? 'restored' : progress.some(item => item.accepted) ? 'partial' : 'blocked', progress,
|
|
408
|
+
remaining_targets: targets.slice(progress.length).map(target => ({ kind: target.kind, id: target.id })) };
|
|
409
|
+
});
|
|
410
|
+
}
|
|
@@ -1,31 +1,39 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
|
-
import {
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
4
5
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
5
6
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
7
|
import { resolveIntegrationConfig } from './integration-config.mjs';
|
|
7
8
|
import { integrationCredentialsPath, readStoredIntegrationKeys } from './integration-credentials.mjs';
|
|
8
9
|
import { registerIntegrationTools } from './integration-tools.mjs';
|
|
9
10
|
import { createIntegrationClients, verificationLines, verifyIntegrationClients } from './integration-verify.mjs';
|
|
11
|
+
import { OPTIONAL_COMPONENT_KEYS, OPTIONAL_INPUTS } from './integration-optional-config.mjs';
|
|
10
12
|
|
|
11
13
|
const packageMetadata = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
12
14
|
|
|
13
|
-
function sideSummary(config) {
|
|
15
|
+
function sideSummary(config, client) {
|
|
14
16
|
if (!config) return Object.freeze({ configured: false });
|
|
17
|
+
const url = config.url ?? client?.origin;
|
|
15
18
|
return Object.freeze({
|
|
16
19
|
configured: true,
|
|
17
20
|
source: config.source,
|
|
18
|
-
binding: config.bindingName,
|
|
19
|
-
host: new URL(
|
|
20
|
-
token_host: new URL(config.tokenUrl).host
|
|
21
|
+
binding: config.bindingName ?? null,
|
|
22
|
+
host: url ? new URL(url).host : null,
|
|
23
|
+
token_host: config.tokenUrl ? new URL(config.tokenUrl).host : null
|
|
21
24
|
});
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
/** Create an injectable server runtime for tests and the stdio executable. */
|
|
25
|
-
export function createIntegrationServer({ env = process.env, fetchImpl, pollIntervalMs, readBackDelayMs } = {}) {
|
|
28
|
+
export function createIntegrationServer({ env = process.env, fetchImpl, execFileImpl, pollIntervalMs, readBackDelayMs } = {}) {
|
|
26
29
|
const config = resolveIntegrationConfig(env, { stored: readStoredIntegrationKeys(env) });
|
|
27
|
-
const summary = Object.freeze({ cpi: sideSummary(config.cpi), apim: sideSummary(config.apim) });
|
|
28
30
|
const clients = createIntegrationClients(config, { env, fetchImpl });
|
|
31
|
+
const summary = Object.freeze({
|
|
32
|
+
environment: config.environment ?? 'default',
|
|
33
|
+
...Object.fromEntries(['cpi', 'apim', 'devhub', ...OPTIONAL_COMPONENT_KEYS].map(side => [side, sideSummary(config[side], clients[side])])),
|
|
34
|
+
optional_config_errors: config.optional_config_errors ?? [],
|
|
35
|
+
common_subaccount: 'unverified'
|
|
36
|
+
});
|
|
29
37
|
const verify = () => verifyIntegrationClients(clients);
|
|
30
38
|
|
|
31
39
|
const server = new McpServer({
|
|
@@ -33,9 +41,11 @@ export function createIntegrationServer({ env = process.env, fetchImpl, pollInte
|
|
|
33
41
|
version: packageMetadata.version
|
|
34
42
|
}, { capabilities: { tools: {} } });
|
|
35
43
|
registerIntegrationTools(server, {
|
|
36
|
-
|
|
37
|
-
apim: clients.apim,
|
|
44
|
+
...clients,
|
|
38
45
|
summary,
|
|
46
|
+
env,
|
|
47
|
+
...(fetchImpl ? { fetchImpl } : {}),
|
|
48
|
+
...(execFileImpl ? { execFileImpl } : {}),
|
|
39
49
|
verify,
|
|
40
50
|
...(pollIntervalMs !== undefined ? { pollIntervalMs } : {}),
|
|
41
51
|
...(readBackDelayMs !== undefined ? { readBackDelayMs } : {})
|
|
@@ -57,8 +67,8 @@ export async function runIntegrationServer({ env = process.env, stdin = process.
|
|
|
57
67
|
'',
|
|
58
68
|
'Starts a stdio MCP server for SAP Integration Suite: Cloud Integration (CPI) packages, iFlows, value mappings,',
|
|
59
69
|
'script collections, security material, Partner Directory and monitoring, and API Management (APIM) providers,',
|
|
60
|
-
'proxies, policies, products and key value maps
|
|
61
|
-
'exact confirmation
|
|
70
|
+
'proxies, policies, products and key value maps; optional Developer Hub onboarding and consumer applications.',
|
|
71
|
+
'Writes require exact confirmation strings. Secrets are write-only and never returned.',
|
|
62
72
|
'',
|
|
63
73
|
'Credentials are read from the MCP host environment (never from mcp.json). Per side, first match wins:',
|
|
64
74
|
' CPI_URL, CPI_TOKEN_URL, CPI_CLIENT_ID, CPI_CLIENT_SECRET explicit CPI OAuth client (all four)',
|
|
@@ -67,11 +77,16 @@ export async function runIntegrationServer({ env = process.env, stdin = process.
|
|
|
67
77
|
' APIM_URL, APIM_TOKEN_URL, APIM_CLIENT_ID, APIM_CLIENT_SECRET explicit APIM OAuth client (all four)',
|
|
68
78
|
' APIM_SERVICE_KEY APIM "apiportal-apiaccess" service key JSON',
|
|
69
79
|
' VCAP_SERVICES + APIM_VCAP_SERVICE (default apimanagement-apiportal), APIM_BINDING',
|
|
80
|
+
' DEVHUB_URL, DEVHUB_TOKEN_URL, DEVHUB_CLIENT_ID, DEVHUB_CLIENT_SECRET explicit Developer Hub OAuth client (all four)',
|
|
81
|
+
' DEVHUB_SERVICE_KEY Developer Hub "devportal-apiaccess" service key JSON',
|
|
82
|
+
' VCAP_SERVICES + DEVHUB_VCAP_SERVICE (default apimanagement-devportal), DEVHUB_BINDING',
|
|
83
|
+
...Object.values(OPTIONAL_INPUTS).map(input => ` ${input.variable.padEnd(58)} existing ${input.label} credential JSON (no provisioning)`),
|
|
84
|
+
' INTEGRATION_ENVIRONMENT non-secret report label; default default, never tenant authority',
|
|
70
85
|
` ${integrationCredentialsPath(env)}`,
|
|
71
86
|
' keys pasted and verified by sap-ai-dev --setup-integration',
|
|
72
|
-
'HTTP_PROXY, HTTPS_PROXY and NO_PROXY are honored.
|
|
87
|
+
'HTTP_PROXY, HTTPS_PROXY and NO_PROXY are honored. Configure core and/or separately credentialed optional components.',
|
|
73
88
|
'',
|
|
74
|
-
' --check
|
|
89
|
+
' --check report configured core access separately from optional capability completeness; no tenant writes',
|
|
75
90
|
''
|
|
76
91
|
].join('\n'));
|
|
77
92
|
return;
|
|
@@ -87,8 +102,11 @@ export async function runIntegrationServer({ env = process.env, stdin = process.
|
|
|
87
102
|
if (process.argv.slice(2).includes('--check')) {
|
|
88
103
|
const report = await runtime.verify();
|
|
89
104
|
const lines = [
|
|
90
|
-
...
|
|
91
|
-
...
|
|
105
|
+
...['cpi', 'apim', 'devhub'].flatMap(side => verificationLines(side.toUpperCase(), runtime.connectionSummary[side].host, report[side])),
|
|
106
|
+
...OPTIONAL_COMPONENT_KEYS.filter(side => report[side]?.configured).map(side =>
|
|
107
|
+
`${side.toUpperCase()} ${runtime.connectionSummary[side].host ?? ''}: ${report[side].result ?? 'untested'} (optional access; activation/entitlement unknown)`),
|
|
108
|
+
`Configured core access: ${report.ok ? 'verified' : 'not verified'}; optional capability completeness: ${report.optional_completeness?.complete === true ? 'complete' : 'unverified/partial'}`,
|
|
109
|
+
...runtime.connectionSummary.optional_config_errors.map(error => `${error.component}: invalid optional configuration (${error.source}); adapter disabled`)
|
|
92
110
|
];
|
|
93
111
|
stdout.write(`${lines.join('\n')}\n`);
|
|
94
112
|
await runtime.close();
|
|
@@ -120,6 +138,6 @@ export async function runIntegrationServer({ env = process.env, stdin = process.
|
|
|
120
138
|
}
|
|
121
139
|
}
|
|
122
140
|
|
|
123
|
-
if (process.argv[1] &&
|
|
141
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
124
142
|
await runIntegrationServer();
|
|
125
143
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Private configuration-aware identities; callers expose hashes, never configuration values.
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { entity } from './integration-common.mjs';
|
|
4
|
+
|
|
5
|
+
const compareUtf8 = (a, b) => Buffer.compare(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'));
|
|
6
|
+
|
|
7
|
+
export function canonicalJson(value) {
|
|
8
|
+
const encode = value => {
|
|
9
|
+
if (Array.isArray(value)) return `[${value.map(entry => encode(entry) ?? 'null').join(',')}]`;
|
|
10
|
+
if (value && typeof value === 'object') {
|
|
11
|
+
return `{${Object.keys(value).sort(compareUtf8).flatMap(key => {
|
|
12
|
+
const encoded = encode(value[key]);
|
|
13
|
+
return encoded === undefined ? [] : [`${JSON.stringify(key)}:${encoded}`];
|
|
14
|
+
}).join(',')}}`;
|
|
15
|
+
}
|
|
16
|
+
return JSON.stringify(value);
|
|
17
|
+
};
|
|
18
|
+
return encode(entity(value));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function canonicalDigest(value) {
|
|
22
|
+
return createHash('sha256').update(canonicalJson(value), 'utf8').digest('hex');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function stateDigest(bundle_sha256, configurations = []) {
|
|
26
|
+
const tuples = configurations.map(configuration => Array.isArray(configuration)
|
|
27
|
+
? [configuration[0], configuration[1], configuration[2]]
|
|
28
|
+
: [configuration.ParameterKey, configuration.ParameterValue, configuration.DataType]);
|
|
29
|
+
tuples.sort((a, b) => compareUtf8(a[0], b[0]));
|
|
30
|
+
return canonicalDigest({ bundle_sha256, configurations: tuples });
|
|
31
|
+
}
|
|
@@ -1,27 +1,63 @@
|
|
|
1
1
|
// MCP tools for SAP Integration Suite: Cloud Integration (CPI) and API Management (APIM).
|
|
2
|
-
// Writes cover create, update, deploy
|
|
2
|
+
// Writes cover create, update, deploy, undeploy and guarded APIM deletion; every write
|
|
3
3
|
// checks an exact confirmation string before any network call. Secrets are write-only.
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { apimToolNames, registerApimTools } from './apim-tools.mjs';
|
|
6
6
|
import { cpiToolNames, registerCpiTools } from './cpi-tools.mjs';
|
|
7
|
+
import { devhubToolNames, registerDevhubTools } from './devhub-tools.mjs';
|
|
7
8
|
import { READ_ONLY_ANNOTATIONS, resultOf } from './integration-common.mjs';
|
|
9
|
+
import { registerIntegrationAnalysisTools, registerMonitoringReadinessTools } from './integration-analysis.mjs';
|
|
10
|
+
import { registerIntegrationReleaseTools } from './integration-release-store.mjs';
|
|
11
|
+
import { registerApimRuntimeTools } from './apim-runtime.mjs';
|
|
12
|
+
import { capabilityCatalog } from './integration-capabilities.mjs';
|
|
13
|
+
import { optionalToolNames, registerOptionalIntegrationTools } from './integration-optional-tools.mjs';
|
|
14
|
+
import { registerIntegrationTransportTools } from './integration-transport.mjs';
|
|
8
15
|
|
|
9
16
|
export { INTEGRATION_MAX_OUTPUT_BYTES } from './integration-common.mjs';
|
|
10
17
|
|
|
11
|
-
|
|
18
|
+
const analysisNames = [];
|
|
19
|
+
registerIntegrationAnalysisTools(name => analysisNames.push(name), {});
|
|
20
|
+
const releaseNames = [];
|
|
21
|
+
registerIntegrationReleaseTools(name => releaseNames.push(name), {});
|
|
22
|
+
const runtimeNames = [];
|
|
23
|
+
registerApimRuntimeTools(name => runtimeNames.push(name), {});
|
|
24
|
+
const monitoringNames = [];
|
|
25
|
+
registerMonitoringReadinessTools(name => monitoringNames.push(name), {});
|
|
26
|
+
const transportNames = [];
|
|
27
|
+
registerIntegrationTransportTools(name => transportNames.push(name), {});
|
|
28
|
+
export const INTEGRATION_TOOL_NAMES = Object.freeze(['integration_connection_info', ...cpiToolNames(), ...apimToolNames(), ...devhubToolNames(), ...analysisNames, ...releaseNames, ...runtimeNames, ...monitoringNames, ...transportNames, ...optionalToolNames()]);
|
|
12
29
|
|
|
13
|
-
/** Register
|
|
14
|
-
export function registerIntegrationTools(server, { cpi, apim, summary, verify, pollIntervalMs = 2000, readBackDelayMs = 5000 } = {}) {
|
|
30
|
+
/** Register only the integration sides whose credentials are configured. */
|
|
31
|
+
export function registerIntegrationTools(server, { cpi, apim, devhub, ans, ctms, event_mesh, aem, aem_semp, open_connectors, edge, summary, verify, env, fetchImpl, execFileImpl, pollIntervalMs = 2000, readBackDelayMs = 5000 } = {}) {
|
|
32
|
+
const registeredNames = [];
|
|
15
33
|
const register = (name, title, description, inputSchema, annotations, handler) => {
|
|
16
|
-
|
|
34
|
+
registeredNames.push(name);
|
|
35
|
+
server.registerTool(name, { title, description, inputSchema, annotations }, async args => resultOf(async () => {
|
|
36
|
+
const value = await handler(args);
|
|
37
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? { ...value, environment: summary?.environment ?? 'default' } : value;
|
|
38
|
+
}));
|
|
17
39
|
};
|
|
18
40
|
|
|
19
41
|
register('integration_connection_info', 'Integration Suite connection info',
|
|
20
|
-
'
|
|
42
|
+
'Report the full component catalog, immutable tenant bindings/environment label, configured core access and separate optional capability evidence. Verification is read-only. Endpoint access does not establish activation, entitlement, common-subaccount identity or continuous monitoring. No credentials returned.',
|
|
21
43
|
{ verify: z.boolean().default(false) },
|
|
22
44
|
READ_ONLY_ANNOTATIONS,
|
|
23
|
-
async ({ verify: online }) =>
|
|
45
|
+
async ({ verify: online }) => {
|
|
46
|
+
const verification = online && verify ? await verify() : undefined;
|
|
47
|
+
return { ...summary, common_subaccount: 'unverified',
|
|
48
|
+
capabilities: capabilityCatalog({ summary, verification, toolNames: registeredNames, optional_config_errors: summary?.optional_config_errors }),
|
|
49
|
+
...(verification ? { verification } : {}) };
|
|
50
|
+
});
|
|
24
51
|
|
|
25
|
-
if (cpi) registerCpiTools(register, cpi, { pollIntervalMs });
|
|
52
|
+
if (cpi) registerCpiTools(register, cpi, { pollIntervalMs, summary });
|
|
53
|
+
if (cpi || apim) registerIntegrationAnalysisTools(register, { cpi, apim, summary });
|
|
54
|
+
if (cpi || apim) registerIntegrationReleaseTools(register, { cpi, apim, summary, env, readBackDelayMs });
|
|
26
55
|
if (apim) registerApimTools(register, apim, { readBackDelayMs });
|
|
56
|
+
if (apim) registerApimRuntimeTools(register, { apim, devhub, env, fetchImpl, execFileImpl });
|
|
57
|
+
if (devhub) registerDevhubTools(register, devhub, { readBackDelayMs });
|
|
58
|
+
registerOptionalIntegrationTools(register, { ans, ctms, event_mesh, aem, aem_semp, open_connectors, edge });
|
|
59
|
+
if (cpi || apim || ctms) registerIntegrationTransportTools(register, { cpi, apim, ctms, summary }, { env });
|
|
60
|
+
if ([cpi, apim, devhub, ans].some(Boolean) || Object.values(summary ?? {}).some(side => side?.configured)) {
|
|
61
|
+
registerMonitoringReadinessTools(register, { cpi, apim, ans, summary, verify });
|
|
62
|
+
}
|
|
27
63
|
}
|