sap-ai-dev-toolkit 0.9.9918 → 0.9.9921

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 (59) hide show
  1. package/.github/agents/sap-integration-architect.agent.md +32 -18
  2. package/.github/agents/sap-integration-developer.agent.md +28 -12
  3. package/.github/skills/integration-suite-delivery/SKILL.md +94 -37
  4. package/.github/skills/sap-integration-architect/SKILL.md +49 -25
  5. package/README.md +72 -15
  6. package/dist/checksums.txt +5 -5
  7. package/dist/vsp-darwin-arm64 +0 -0
  8. package/dist/vsp-darwin-x64 +0 -0
  9. package/dist/vsp-linux-arm64 +0 -0
  10. package/dist/vsp-linux-x64 +0 -0
  11. package/dist/vsp-win32-x64.exe +0 -0
  12. package/inventory.md +97 -57
  13. package/package.json +1 -1
  14. package/scripts/check-live-integration.mjs +151 -0
  15. package/scripts/install-local.mjs +11 -4
  16. package/scripts/run-live-integration.mjs +55 -63
  17. package/src/apim-bundle.mjs +416 -13
  18. package/src/apim-cleanup-tools.mjs +145 -0
  19. package/src/apim-content.mjs +157 -0
  20. package/src/apim-delete-tools.mjs +139 -0
  21. package/src/apim-documentation.mjs +162 -0
  22. package/src/apim-inspection.mjs +141 -0
  23. package/src/apim-monitoring.mjs +140 -0
  24. package/src/apim-policy-steps.mjs +31 -0
  25. package/src/apim-runtime.mjs +262 -0
  26. package/src/apim-tools.mjs +202 -109
  27. package/src/cf-integration.mjs +46 -7
  28. package/src/cpi-async-monitoring.mjs +177 -0
  29. package/src/cpi-content.mjs +92 -0
  30. package/src/cpi-monitoring.mjs +225 -0
  31. package/src/cpi-operational.mjs +129 -0
  32. package/src/cpi-release.mjs +273 -0
  33. package/src/cpi-security.mjs +48 -0
  34. package/src/cpi-tools.mjs +184 -319
  35. package/src/devhub-retirement.mjs +345 -0
  36. package/src/devhub-tools.mjs +177 -0
  37. package/src/edge-monitoring.mjs +101 -0
  38. package/src/iflow-bundle.mjs +295 -7
  39. package/src/integration-alerts.mjs +248 -0
  40. package/src/integration-analysis.mjs +488 -0
  41. package/src/integration-capabilities.mjs +181 -0
  42. package/src/integration-collections.mjs +203 -0
  43. package/src/integration-common.mjs +140 -35
  44. package/src/integration-config.mjs +20 -14
  45. package/src/integration-credentials.mjs +16 -10
  46. package/src/integration-http.mjs +255 -67
  47. package/src/integration-messaging.mjs +118 -0
  48. package/src/integration-optional-config.mjs +122 -0
  49. package/src/integration-optional-tools.mjs +153 -0
  50. package/src/integration-release-store.mjs +410 -0
  51. package/src/integration-server.mjs +34 -16
  52. package/src/integration-state.mjs +31 -0
  53. package/src/integration-tools.mjs +44 -8
  54. package/src/integration-transport.mjs +184 -0
  55. package/src/integration-validation.mjs +26 -0
  56. package/src/integration-verify.mjs +143 -62
  57. package/src/open-connectors-tools.mjs +143 -0
  58. package/src/setup.mjs +86 -52
  59. package/tools.md +193 -26
@@ -4,6 +4,7 @@ import { z } from 'zod';
4
4
  import { INTEGRATION_CONFIG_ERROR } from './integration-config.mjs';
5
5
  import { IntegrationError, odataKey } from './integration-http.mjs';
6
6
  import { strFromU8 } from './iflow-bundle.mjs';
7
+ import { createCollectionPager, cursorSchema } from './integration-collections.mjs';
7
8
 
8
9
  export const INTEGRATION_MAX_OUTPUT_BYTES = 96 * 1024;
9
10
  export const FILE_TEXT_CAP = 48 * 1024;
@@ -69,6 +70,9 @@ export function requireConfirmation(actual, expected) {
69
70
  export function list(value) {
70
71
  if (Array.isArray(value)) return value;
71
72
  if (Array.isArray(value?.results)) return value.results;
73
+ if (Array.isArray(value?.value)) return value.value;
74
+ if (Array.isArray(value?.d?.results)) return value.d.results;
75
+ if (Array.isArray(value?.d)) return value.d;
72
76
  return [];
73
77
  }
74
78
 
@@ -78,7 +82,7 @@ export function entity(value) {
78
82
  if (!value || typeof value !== 'object') return value;
79
83
  const result = {};
80
84
  for (const [name, entry] of Object.entries(value)) {
81
- if (name.startsWith('__')) continue;
85
+ if (name.startsWith('__') || name.startsWith('@odata.') || name === '@count') continue;
82
86
  if (entry && typeof entry === 'object' && !Array.isArray(entry) && entry.__deferred) continue;
83
87
  result[name] = entry && typeof entry === 'object' && Array.isArray(entry.results) ? entity(entry.results) : entity(entry);
84
88
  }
@@ -91,23 +95,23 @@ const PROVIDER_SECRET_KEYS = new Set(['password', 'clientSecret', 'userSecret',
91
95
  export function withoutSecrets(value) {
92
96
  if (Array.isArray(value)) return value.map(withoutSecrets);
93
97
  if (!value || typeof value !== 'object') return value;
98
+ const semanticName = value.Name ?? value.name ?? value.ParameterKey;
94
99
  return Object.fromEntries(Object.entries(value)
95
100
  .filter(([name]) => !PROVIDER_SECRET_KEYS.has(name))
96
- .map(([name, entry]) => [name, withoutSecrets(entry)]));
101
+ .map(([name, entry]) => [name, /^(?:value|ParameterValue)$/i.test(name) && typeof semanticName === 'string'
102
+ && /password|secret|token|username|clientid|authorization/i.test(semanticName) ? REDACTED : withoutSecrets(entry)]));
97
103
  }
98
104
 
99
105
  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;
106
+ const SECRET_FIELD = /password|secret|passphrase|private[_-]?key|api[_-]?key|app[_-]?key|consumer[_-]?key|secure[_-]?param|access[_-]?token|refresh[_-]?token|authorization|cookie|(?:^|[_-])token(?:$|[_-])|csrf[_-]?token/i;
101
107
  const BINARY_FIELDS = new Set(['ArtifactContent', 'ResourceContent', 'PackageContent', 'CustomTagsConfigurationContent']);
102
108
 
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
- */
109
+ /** Redact known credential fields and semantic name/value pairs, not arbitrary source text. */
107
110
  export function redactSecrets(value, encrypted = false) {
108
111
  if (Array.isArray(value)) return value.map(entry => redactSecrets(entry, encrypted));
109
112
  if (!value || typeof value !== 'object') return value;
110
- const inEncrypted = encrypted || value.encrypted === true || value.isEncrypted === true;
113
+ const inEncrypted = encrypted || value.encrypted === true || value.encrypted === 'true' || value.isEncrypted === true || value.isEncrypted === 'true';
114
+ const semanticName = value.Name ?? value.name ?? value.ParameterKey;
111
115
  const result = {};
112
116
  for (const [name, entry] of Object.entries(value)) {
113
117
  if (BINARY_FIELDS.has(name)) {
@@ -115,11 +119,11 @@ export function redactSecrets(value, encrypted = false) {
115
119
  continue;
116
120
  }
117
121
  if (entry !== null && entry !== undefined && entry !== '' && typeof entry !== 'object'
118
- && (SECRET_FIELD.test(name) || (inEncrypted && name === 'value'))) {
122
+ && (SECRET_FIELD.test(name) || ((inEncrypted || (typeof semanticName === 'string' && SECRET_FIELD.test(semanticName))) && /^(?:value|ParameterValue)$/i.test(name)))) {
119
123
  result[name] = REDACTED;
120
124
  continue;
121
125
  }
122
- result[name] = redactSecrets(entry, inEncrypted);
126
+ result[name] = redactSecrets(entry, inEncrypted || name === 'keyMapEntryValues');
123
127
  }
124
128
  return result;
125
129
  }
@@ -136,7 +140,17 @@ export function capText(text, limit) {
136
140
  }
137
141
 
138
142
  export function isoToOdataDateTime(value) {
139
- return new Date(value).toISOString().slice(0, 19);
143
+ return new Date(value).toISOString().slice(0, -1);
144
+ }
145
+
146
+ /** Shared UTC half-open window for monitoring; milliseconds remain significant. */
147
+ export function timeWindow(from, to) {
148
+ if (!isoDate.safeParse(from).success || !isoDate.safeParse(to).success) throw new IntegrationError('from and to must both be valid UTC timestamps');
149
+ const start = Date.parse(from);
150
+ const end = Date.parse(to);
151
+ if (end <= start) throw new IntegrationError('to must be after from');
152
+ if (end - start > 31 * 24 * 60 * 60 * 1000) throw new IntegrationError('The monitoring window must not exceed 31 days');
153
+ return { from: new Date(start).toISOString(), to: new Date(end).toISOString() };
140
154
  }
141
155
 
142
156
  function extensionOf(path) {
@@ -172,7 +186,9 @@ export function bundleFiles(bundle, filePath) {
172
186
  // ---------------------------------------------------------------- schemas
173
187
 
174
188
  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');
189
+ export const isoDate = z.string().max(40)
190
+ .regex(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/, 'must be an explicit UTC timestamp')
191
+ .refine(value => Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 19) === value.slice(0, 19), 'must be a valid UTC date/time');
176
192
  export const top = (max = 200, fallback = 100) => z.number().int().min(1).max(max).default(fallback);
177
193
  export const secretValue = z.string().min(1).max(4096)
178
194
  .describe('Write-only: sent to the tenant once and never echoed back by any tool.');
@@ -183,6 +199,97 @@ const ODATA_SEGMENT = String.raw`[A-Za-z][A-Za-z0-9_]{0,99}(?:\([^/?#]{0,400}\))
183
199
  const ODATA_PATH = new RegExp(String.raw`^(?:/\$metadata|/${ODATA_SEGMENT}(?:/(?:${ODATA_SEGMENT}|\$count))*)$`);
184
200
  const ODATA_OPTIONS = ['$filter', '$select', '$expand', '$orderby', '$top', '$skip', '$inlinecount'];
185
201
 
202
+ // Version 1: only implemented metadata projections authorize data access. Metadata discovery
203
+ // does not authorize raw reads of newly discovered roots or GET function imports.
204
+ const fields = names => value => pick(entity(value), names.split(','));
205
+ const CPI_PROJECTORS = {
206
+ IntegrationPackages: fields('Id,Name,ShortText,Version,Mode'),
207
+ IntegrationDesigntimeArtifacts: fields('Id,Name,Version,Description,PackageId'),
208
+ ValueMappingDesigntimeArtifacts: fields('Id,Name,Version,Description,PackageId'),
209
+ ScriptCollectionDesigntimeArtifacts: fields('Id,Name,Version,Description,PackageId'),
210
+ MessageMappingDesigntimeArtifacts: fields('Id,Name,Version,Description,PackageId'),
211
+ Configurations: fields('ParameterKey,DataType'),
212
+ IntegrationRuntimeArtifacts: fields('Id,Name,Version,Type,Status,DeployedOn'),
213
+ MessageProcessingLogs: fields('MessageGuid,CorrelationId,ApplicationMessageId,IntegrationFlowName,Status,CustomStatus,LogStart,LogEnd'),
214
+ Runs: fields('Id,RunId,MessageGuid,LogStart,LogEnd,Status'),
215
+ MessageProcessingLogRuns: fields('Id,RunId,MessageGuid,LogStart,LogEnd,Status'),
216
+ RunSteps: fields('StepId,ModelStepId,Activity,Status,StepStart,StepStop'),
217
+ CustomHeaderProperties: fields('Name'),
218
+ JmsBrokers: fields('Name,Capacity,MaxCapacity,CapacityOk,CapacityWarning,CapacityError,QueueNumber,MaxQueueNumber,IsQueuesHigh,IsMessageSpoolHigh,IsTransactedSessionsHigh,IsConsumersHigh,IsProducersHigh'),
219
+ QueueStates: fields('Name,State'),
220
+ InactiveQueues: fields('Name,State'),
221
+ Queues: fields('Name,NumbOfMsgs,Size,Type,State,FillGrade,Active,Exclusive'),
222
+ DataStores: fields('DataStoreName,IntegrationFlow,Type,Visibility,NumberOfMessages,NumberOfOverdueMessages'),
223
+ Entries: fields('Id,Status,MessageId,DueAt,CreatedAt,RetainUntil'),
224
+ Variables: fields('Name,IntegrationFlow,Visibility,UpdatedAt,RetainUntil'),
225
+ UserCredentials: fields('Name,Kind,DeployedOn,Status'),
226
+ OAuth2ClientCredentials: fields('Name,Status'),
227
+ KeystoreEntries: fields('Alias,Type,KeyType,ValidNotBefore,ValidNotAfter'),
228
+ NumberRanges: fields('Name,Description,MinValue,MaxValue,CurrentValue,Rotate,FieldLength'),
229
+ StringParameters: fields('Pid,Id'),
230
+ BinaryParameters: fields('Pid,Id,ContentType')
231
+ };
232
+ const APIM_PROJECTORS = {
233
+ APIProviders: fields('name,title,destType,host,port,useSSL,isOnPremise,cloudConnectorLocation,pathPrefix'),
234
+ APIProviderAdditionalPropertys: value => ({ ...pick(value, ['entityId', 'name']), ...(/^(?:sap-client)$/i.test(value?.name ?? '') ? pick(value, ['value']) : {}) }),
235
+ APIProxies: fields('name,title,service_code,state,version,provider_name'),
236
+ APIProducts: fields('name,title,status,scope,quotaCount,quotaInterval,quotaTimeUnit'),
237
+ VirtualHosts: fields('id,name,virtual_host,virtual_port,isSSL,isDefault'),
238
+ KeyMapEntries: fields('name,encrypted,scope'),
239
+ keyMapEntryValues: value => ({ ...pick(value, ['name']), value: REDACTED }),
240
+ KeyMapEntryValues: value => ({ ...pick(value, ['name']), value: REDACTED }),
241
+ Applications: fields('id,title,version,status,status_code'),
242
+ Developers: fields('id,status,status_code'),
243
+ CertificateStoreReferences: fields('id,name,alias,type')
244
+ };
245
+ const CPI_NAVIGATIONS = {
246
+ IntegrationPackages: ['IntegrationDesigntimeArtifacts', 'ValueMappingDesigntimeArtifacts', 'ScriptCollectionDesigntimeArtifacts', 'MessageMappingDesigntimeArtifacts'],
247
+ IntegrationDesigntimeArtifacts: ['Configurations'],
248
+ MessageProcessingLogs: ['Runs', 'CustomHeaderProperties'],
249
+ MessageProcessingLogRuns: ['RunSteps'],
250
+ DataStores: ['Entries'],
251
+ JmsBrokers: ['QueueStates', 'InactiveQueues']
252
+ };
253
+ const APIM_NAVIGATIONS = { KeyMapEntries: ['keyMapEntryValues'] };
254
+ const LOCAL_ROOTS = new Set(['DataStores', 'Entries', 'Variables', 'Queues', 'JmsBrokers', 'QueueStates', 'InactiveQueues', 'NumberRanges', 'UserCredentials', 'OAuth2ClientCredentials', 'KeystoreEntries']);
255
+ const COUNT_ROOTS = new Set(['MessageProcessingLogs', 'IntegrationPackages', 'IntegrationRuntimeArtifacts', 'APIProxies', 'APIProviders', 'APIProducts', 'KeyMapEntries']);
256
+
257
+ function readPolicy(name, path, query) {
258
+ const cpi = name === 'cpi_odata_get';
259
+ const projectors = cpi ? CPI_PROJECTORS : APIM_PROJECTORS;
260
+ const navigations = cpi ? CPI_NAVIGATIONS : APIM_NAVIGATIONS;
261
+ const parts = path.slice(1).split('/');
262
+ const count = parts.at(-1) === '$count';
263
+ if (count) parts.pop();
264
+ if (parts.length > 2) throw new IntegrationError('Only one documented metadata navigation level is allowed');
265
+ const roots = parts.map(part => /^([A-Za-z][A-Za-z0-9_]*)(?:\([^/]*\))?$/.exec(part)?.[1]);
266
+ const [root, navigation] = roots;
267
+ if (!root || !projectors[root] || (navigation && !navigations[root]?.includes(navigation))) {
268
+ throw new IntegrationError('This route has no safe metadata projector; use /$metadata for discovery or a dedicated tool');
269
+ }
270
+ if (count && (navigation || !COUNT_ROOTS.has(root))) throw new IntegrationError('A count contract is not established for this route');
271
+ for (const [option, value] of Object.entries(query)) {
272
+ if (!ODATA_OPTIONS.includes(option) && !(cpi && root === 'KeystoreEntries' && option === 'keystoreName')) throw new IntegrationError(`query option ${option} is not allowed`);
273
+ if (option === '$top' && !/^(?:[1-9]\d?|1\d\d|200)$/.test(value)) throw new IntegrationError('$top must be an integer from 1 to 200');
274
+ if (option === '$skip' && (!/^\d{1,7}$/.test(value) || Number(value) > 1_000_000)) throw new IntegrationError('$skip must be a bounded nonnegative integer');
275
+ if (option === '$inlinecount' && !['allpages', 'none'].includes(value)) throw new IntegrationError('$inlinecount must be allpages or none');
276
+ if (option === 'keystoreName' && !['system', 'backup_admin_system', 'KeyRenewal'].includes(value)) throw new IntegrationError('Unknown keystoreName');
277
+ if (option === '$select' && !/^[A-Za-z][A-Za-z0-9_]*(?:,[A-Za-z][A-Za-z0-9_]*)*$/.test(value)) throw new IntegrationError('$select must contain plain field names');
278
+ if (option === '$expand' && value.split(',').some(entry => !navigations[navigation ?? root]?.includes(entry))) throw new IntegrationError('$expand is not an allowed metadata navigation');
279
+ if (option === '$orderby' && !/^[A-Za-z][A-Za-z0-9_]*(?: (?:asc|desc))?$/.test(value)) throw new IntegrationError('$orderby must contain one plain field');
280
+ }
281
+ const selected = navigation ?? root;
282
+ const project = value => {
283
+ const result = projectors[selected](value);
284
+ for (const child of (query.$expand ?? '').split(',').filter(Boolean)) {
285
+ const raw = value?.[child];
286
+ if (raw !== undefined) result[child] = list(raw).map(projectors[child]);
287
+ }
288
+ return redactSecrets(withoutSecrets(result));
289
+ };
290
+ return { project, collection: parts.length === 2 || !parts[0].includes('('), mode: LOCAL_ROOTS.has(selected) ? 'local' : 'odata', count };
291
+ }
292
+
186
293
  /** Summarize an OData $metadata document into entity sets and function imports. */
187
294
  export function metadataSummary(xml) {
188
295
  const attribute = (tag, name) => tag.match(new RegExp(`\\b${name}="([^"]*)"`))?.[1];
@@ -195,38 +302,36 @@ export function metadataSummary(xml) {
195
302
  return { entity_sets: entitySets, function_imports: functionImports };
196
303
  }
197
304
 
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 }) {
305
+ /** A metadata-only, versioned route allowlist; never an arbitrary GET passthrough. */
306
+ export function registerODataReader(register, { name, title, service, request, client, root, examples }) {
307
+ const pager = createCollectionPager(client ?? request, { root, request });
203
308
  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.`,
309
+ `Read allowlisted operational metadata from ${service}. Discover entity/path names with /$metadata; only implemented safe projectors authorize reads. No function imports, payloads, source, arbitrary expansions, configuration values or secrets. Examples: ${examples}. Collections use signed bounded cursors.`,
205
310
  {
206
- path: z.string().max(1000).regex(ODATA_PATH, 'must be /$metadata or /EntitySet[(key)][/Navigation...][/$count] without $value, query string or ..')
311
+ path: z.string().max(1000).regex(ODATA_PATH, 'must be /$metadata or a metadata entity path without query string or media')
207
312
  .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).`)
313
+ query: z.record(z.string().max(50), z.string().max(2000)).optional(),
314
+ top: top(),
315
+ cursor: cursorSchema
210
316
  },
211
317
  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
- }
318
+ async ({ path, query = {}, top: pageSize, cursor }) => {
216
319
  if (path === '/$metadata') {
320
+ if (cursor || Object.keys(query).length) throw new IntegrationError('Metadata discovery accepts no cursor or query options');
217
321
  return metadataSummary(await request('GET', path, { accept: 'application/xml', responseType: 'text' }));
218
322
  }
219
- if (path.endsWith('/$count')) {
323
+ const policy = readPolicy(name, path, query);
324
+ if (policy.count) {
325
+ if (cursor) throw new IntegrationError('Count reads accept no cursor');
220
326
  const text = await request('GET', path, { query, accept: 'text/plain', responseType: 'text' });
221
- return { count: Number(String(text).trim()) };
327
+ const trimmed = String(text).trim();
328
+ if (!/^\d+$/.test(trimmed) || !Number.isSafeInteger(Number(trimmed))) throw new IntegrationError('Count endpoint returned invalid nonnegative integer text');
329
+ return { count: Number(trimmed) };
330
+ }
331
+ if (policy.collection) {
332
+ return pager.page({ path, query, top: Number(query.$top ?? pageSize), cursor, project: policy.project, mode: policy.mode, orderBy: query.$orderby });
222
333
  }
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;
334
+ if (cursor) throw new IntegrationError('Named metadata reads accept no cursor');
335
+ return policy.project(await request('GET', path, { query }));
231
336
  });
232
337
  }
@@ -1,12 +1,13 @@
1
- // Resolves CPI (Cloud Integration) and APIM (API Management) OAuth client
2
- // credentials from the MCP host environment. Pure: no I/O, no network.
3
- // Every error message starts with a stable prefix (Explicit CPI/APIM, CPI_,
4
- // APIM_, No CPI/APIM, Multiple CPI/APIM, Configure CPI) so the tool layer
5
- // can surface it verbatim; no message ever echoes a secret value.
1
+ // Resolves core OAuth credentials and independently configured optional services.
2
+ // Pure: no I/O, no network. Stable error prefixes allow the tool layer to
3
+ // surface configuration failures without echoing secret values.
4
+ import { OPTIONAL_COMPONENT_KEYS, resolveOptionalConfig } from './integration-optional-config.mjs';
5
+
6
6
 
7
7
  const SIDES = Object.freeze({
8
8
  CPI: Object.freeze({ prefix: 'CPI', defaultVcapService: 'it-rt' }),
9
- APIM: Object.freeze({ prefix: 'APIM', defaultVcapService: 'apimanagement-apiportal' })
9
+ APIM: Object.freeze({ prefix: 'APIM', defaultVcapService: 'apimanagement-apiportal' }),
10
+ DEVHUB: Object.freeze({ prefix: 'DEVHUB', defaultVcapService: 'apimanagement-devportal' })
10
11
  });
11
12
 
12
13
  const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
@@ -65,7 +66,7 @@ function explicitConfig(env, prefix) {
65
66
  });
66
67
  }
67
68
 
68
- /** Parse a CPI/APIM service key (nested `oauth` or flat APIM shape) into raw OAuth fields. */
69
+ /** Parse an Integration Suite service key (nested `oauth` or flat shape) into raw OAuth fields. */
69
70
  function parseServiceKey(value, origin) {
70
71
  let key = value;
71
72
  if (typeof value === 'string') {
@@ -146,22 +147,27 @@ export function resolveIntegrationSideFromEnvironment(env, side) {
146
147
  return resolveSide(env, SIDES[side]);
147
148
  }
148
149
 
149
- /** Validate a pasted CPI/APIM service key (JSON text or object); returns the resolved side config. */
150
+ /** Validate a pasted Integration Suite service key (JSON text or object); returns the resolved side config. */
150
151
  export function parseIntegrationServiceKey(side, value) {
151
152
  return finalize(side, 'stored-key', null, parseServiceKey(value, `${side} service key`));
152
153
  }
153
154
 
154
155
  /**
155
- * Resolve CPI and APIM client credentials; at least one side must be configured.
156
- * `stored` holds service keys saved by the setup wizard ({ cpi, apim }) and has the lowest precedence.
156
+ * Resolve core and optional credentials; absent configuration retains setup guidance.
157
+ * `stored` holds setup wizard keys with lowest precedence.
157
158
  */
158
159
  export function resolveIntegrationConfig(env = process.env, { stored } = {}) {
159
160
  const cpi = resolveSide(env, SIDES.CPI, stored);
160
161
  const apim = resolveSide(env, SIDES.APIM, stored);
161
- if (!cpi && !apim) {
162
- fail('Configure CPI (CPI_SERVICE_KEY or CPI_URL/CPI_TOKEN_URL/CPI_CLIENT_ID/CPI_CLIENT_SECRET) and/or APIM (APIM_SERVICE_KEY or APIM_*) credentials in the MCP host environment, or run sap-ai-dev --setup-integration to paste and verify service keys.');
162
+ const devhub = resolveSide(env, SIDES.DEVHUB, stored);
163
+ const optional = resolveOptionalConfig(env, stored);
164
+ if (!cpi && !apim && !devhub && !OPTIONAL_COMPONENT_KEYS.some(side => optional[side]) && !optional.optional_config_errors.length) {
165
+ fail('Configure CPI (CPI_SERVICE_KEY or CPI_URL/CPI_TOKEN_URL/CPI_CLIENT_ID/CPI_CLIENT_SECRET), APIM (APIM_SERVICE_KEY or APIM_*), and/or DEVHUB (DEVHUB_SERVICE_KEY or DEVHUB_URL/DEVHUB_TOKEN_URL/DEVHUB_CLIENT_ID/DEVHUB_CLIENT_SECRET) credentials in the MCP host environment, or run sap-ai-dev --setup-integration to paste and verify service keys.');
163
166
  }
164
- return Object.freeze({ cpi, apim });
167
+ const environment = typeof env.INTEGRATION_ENVIRONMENT === 'string' && env.INTEGRATION_ENVIRONMENT.trim()
168
+ ? env.INTEGRATION_ENVIRONMENT.trim()
169
+ : 'default';
170
+ return Object.freeze({ cpi, apim, devhub, ...optional, environment });
165
171
  }
166
172
 
167
- export const INTEGRATION_CONFIG_ERROR = /^(?:Explicit CPI|Explicit APIM|CPI_|APIM_|No CPI|No APIM|Multiple CPI|Multiple APIM|Configure CPI|Stored (?:CPI|APIM)|Stored Integration Suite)/;
173
+ export const INTEGRATION_CONFIG_ERROR = /^(?:Explicit (?:CPI|APIM|DEVHUB)|(?:CPI|APIM|DEVHUB)_|No (?:CPI|APIM|DEVHUB)|Multiple (?:CPI|APIM|DEVHUB)|Configure CPI|Stored (?:CPI|APIM|DEVHUB)|Stored Integration Suite)/;
@@ -1,13 +1,14 @@
1
- // User-only store for CPI/APIM service keys pasted into `sap-ai-dev --setup-integration`.
2
- // The file lives outside mcp.json (~/.config/sap-ai-dev-toolkit/integration-suite.json, mode 0600)
3
- // and keeps only the four OAuth fields the companion needs; environment credentials take precedence.
1
+ // User-only store for core service keys and normalized optional-service credentials.
2
+ // The file lives outside mcp.json (~/.config/sap-ai-dev-toolkit/integration-suite.json, mode 0600).
3
+ // Environment credentials take precedence; source metadata is never stored.
4
4
  import { readFileSync } from 'node:fs';
5
5
  import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises';
6
6
  import { homedir } from 'node:os';
7
7
  import { dirname, join } from 'node:path';
8
8
  import { randomUUID } from 'node:crypto';
9
+ import { OPTIONAL_COMPONENT_KEYS, optionalConfigRecord } from './integration-optional-config.mjs';
9
10
 
10
- const SIDES = ['cpi', 'apim'];
11
+ const SIDES = ['cpi', 'apim', 'devhub', ...OPTIONAL_COMPONENT_KEYS];
11
12
 
12
13
  export function integrationCredentialsPath(env = process.env) {
13
14
  const configHome = typeof env.XDG_CONFIG_HOME === 'string' && env.XDG_CONFIG_HOME.trim()
@@ -16,7 +17,7 @@ export function integrationCredentialsPath(env = process.env) {
16
17
  return join(configHome, 'sap-ai-dev-toolkit', 'integration-suite.json');
17
18
  }
18
19
 
19
- /** Stored service keys ({ cpi?, apim? }); {} when nothing was stored. */
20
+ /** Stored keys/normalized optional records; malformed optional records remain isolated by the resolver. */
20
21
  export function readStoredIntegrationKeys(env = process.env) {
21
22
  const path = integrationCredentialsPath(env);
22
23
  let text;
@@ -32,23 +33,28 @@ export function readStoredIntegrationKeys(env = process.env) {
32
33
  } catch {
33
34
  throw new Error(`Stored Integration Suite credentials at ${path} are not valid JSON; rerun sap-ai-dev --setup-integration`);
34
35
  }
35
- return Object.fromEntries(SIDES.filter(side => parsed?.[side]).map(side => [side, parsed[side]]));
36
+ return Object.fromEntries(SIDES.filter(side => Object.hasOwn(parsed || {}, side)).map(side => [side, parsed[side]]));
36
37
  }
37
38
 
38
- /** Save one side's verified OAuth client (resolved config) next to the other side's stored key. */
39
+ /** Save one side alongside other stored keys; optional records contain only typed normalized fields. */
39
40
  export async function storeIntegrationKey(side, config, env = process.env) {
40
41
  const key = side.toLowerCase();
41
42
  if (!SIDES.includes(key)) throw new Error(`Unknown Integration Suite side ${side}`);
43
+ const optional = OPTIONAL_COMPONENT_KEYS.includes(key);
44
+ const record = optional ? optionalConfigRecord(key, config) : {
45
+ oauth: { url: config.url, tokenurl: config.tokenUrl, clientid: config.clientId, clientsecret: config.clientSecret }
46
+ };
42
47
  const path = integrationCredentialsPath(env);
43
48
  let existing = {};
44
49
  try {
45
50
  existing = readStoredIntegrationKeys(env);
46
- } catch {
47
- // An unreadable or corrupt store is replaced by the verified key.
51
+ } catch (error) {
52
+ // Optional setup never replaces a store it could not read; core setup behavior is unchanged.
53
+ if (optional) throw error;
48
54
  }
49
55
  const next = {
50
56
  ...existing,
51
- [key]: { oauth: { url: config.url, tokenurl: config.tokenUrl, clientid: config.clientId, clientsecret: config.clientSecret } }
57
+ [key]: record
52
58
  };
53
59
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
54
60
  await chmod(dirname(path), 0o700).catch(() => {});