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.
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 +73 -14
  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 +52 -60
  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 +66 -44
  59. package/tools.md +193 -26
@@ -1,6 +1,8 @@
1
1
  // MCP tools for SAP API Management (APIM) over the API portal Management.svc / Transport.svc APIs.
2
2
  import { z } from 'zod';
3
- import { attachPolicies, buildApiProxyBundle, bundlePolicyNames, patchProxyBundle } from './apim-bundle.mjs';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { attachPolicies, buildApiProxyBundle, bundleDocumentation, bundlePolicyNames, patchBundleDocumentation, patchProxyBundle } from './apim-bundle.mjs';
5
+ import { DOCUMENTATION_METHODS, MAX_DOCUMENTATION_BYTES, parseApiDocumentation, summarizeApiDocumentation } from './apim-documentation.mjs';
4
6
  import {
5
7
  CREATE_ANNOTATIONS,
6
8
  OVERWRITE_ANNOTATIONS,
@@ -12,18 +14,21 @@ import {
12
14
  key,
13
15
  list,
14
16
  pick,
17
+ redactSecrets,
15
18
  registerODataReader,
16
19
  requireConfirmation,
17
20
  secretValue,
18
- sleep,
19
21
  top,
20
22
  withoutSecrets
21
23
  } from './integration-common.mjs';
22
24
  import { IntegrationError, odataKey } from './integration-http.mjs';
23
25
  import { readBundle, writeBundle } from './iflow-bundle.mjs';
26
+ import { registerApimDeleteTools } from './apim-delete-tools.mjs';
27
+ import { createCollectionPager, cursorSchema } from './integration-collections.mjs';
28
+ import { APIM_BASE, exportApiProxyBundle, importApiProxyBundle, readApiProxy } from './apim-content.mjs';
29
+ import { registerApimMonitoringTools } from './apim-monitoring.mjs';
30
+ import { registerApimPolicyStepTools } from './apim-policy-steps.mjs';
24
31
 
25
- const APIM_BASE = '/apiportal/api/1.0/Management.svc';
26
- const APIM_TRANSPORT = '/apiportal/api/1.0/Transport.svc/APIProxies';
27
32
  const RESERVED_PROVIDER_NAMES = new Set(['DEST_CI', 'APIMGMT_PORTAL', 'ContentCatalog', 'ON_PREM', 'TC']);
28
33
 
29
34
  const apimName = z.string().regex(/^[A-Za-z][A-Za-z0-9_-]{0,99}$/);
@@ -148,22 +153,54 @@ function withoutValues(error, values) {
148
153
  return new IntegrationError(message);
149
154
  }
150
155
 
156
+ function requireNamedMetadata(value, name, kind) {
157
+ if (!value || typeof value !== 'object' || Array.isArray(value) || value.name !== name) {
158
+ throw new IntegrationError(`${kind} ${name} returned malformed metadata; response withheld`);
159
+ }
160
+ return value;
161
+ }
162
+
163
+ // Management metadata is not an authoring/source surface. Keep operational URL
164
+ // paths, but never return embedded credentials or request query/fragment values.
165
+ function safeMetadata(value) {
166
+ if (Array.isArray(value)) return value.map(safeMetadata);
167
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([name, entry]) => [name, safeMetadata(entry)]));
168
+ if (typeof value === 'string' && /^https?:\/\//i.test(value)) {
169
+ try {
170
+ const url = new URL(value);
171
+ url.username = ''; url.password = ''; url.search = ''; url.hash = '';
172
+ return url.href;
173
+ } catch { return null; }
174
+ }
175
+ return value;
176
+ }
177
+
178
+
179
+
151
180
  /** Register apim_* tools for one APIM client. */
152
181
  export function registerApimTools(register, client, { readBackDelayMs }) {
153
182
  const apim = (method, path, options) => client.request(method, `${APIM_BASE}${path}`, options);
154
-
155
- async function exists(path) {
156
- const { status } = await apim('GET', path, { expect: [200, 404], withStatus: true });
157
- return status === 200;
183
+ const collections = createCollectionPager(client, { root: APIM_BASE, request: apim });
184
+ registerApimDeleteTools(register, { request: apim, readBackDelayMs });
185
+ registerApimMonitoringTools(register, client);
186
+ registerApimPolicyStepTools(register, client, { readBackDelayMs });
187
+
188
+ async function exists(collection, name) {
189
+ // Name-only collection reads avoid resolving provider destinations or proxy details.
190
+ const matches = list(await apim('GET', collection, {
191
+ query: { $filter: `name eq ${odataKey(name)}`, $select: 'name', $top: 1 }
192
+ }));
193
+ return matches.some(entry => entry.name === name);
158
194
  }
159
195
 
160
196
  async function getProvider(name) {
161
197
  const { status, data } = await apim('GET', `/APIProviders(${key(name)})`, { expect: [200, 404], withStatus: true });
162
198
  if (status === 404) return null;
199
+ const provider = requireNamedMetadata(entity(data), name, 'API provider');
163
200
  // $expand=additionalProperties returns an empty list on live tenants; the navigation link is reliable.
164
201
  const additional = list(await apim('GET', `/APIProviders(${key(name)})/additionalProperties`))
165
202
  .map(entry => pick(entry, ['name', 'value']));
166
- return withoutSecrets({ ...entity(data), additionalProperties: additional });
203
+ return safeMetadata(withoutSecrets(redactSecrets({ ...provider, additionalProperties: additional })));
167
204
  }
168
205
 
169
206
  async function writeSapClient(name, sapClient) {
@@ -173,48 +210,9 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
173
210
  });
174
211
  }
175
212
 
176
- function runtimeUrl(proxy, defaultHost) {
177
- const candidates = [proxy?.publishUrl, ...list(proxy?.deploymentInfo).map(entry => entry?.publishUrl), proxy?.deploymentInfo?.publishUrl];
178
- const published = candidates.find(value => typeof value === 'string' && /^https?:\/\/\S+$/.test(value));
179
- if (published) return published;
180
- const endpoint = list(proxy?.proxyEndPoints)[0];
181
- const host = list(endpoint?.virtualhosts)[0] ?? defaultHost;
182
- if (!endpoint || !host?.virtual_host) return null;
183
- const port = Number(host.virtual_port);
184
- const portText = !port || port === 443 || port === 80 ? '' : `:${port}`;
185
- return `${host.isSSL ? 'https' : 'http'}://${host.virtual_host}${portText}${endpoint.base_path ?? ''}`;
186
- }
187
213
 
188
- async function getProxy(name) {
189
- const { status, data } = await apim('GET', `/APIProxies(${key(name)})`, {
190
- query: { $expand: 'proxyEndPoints,targetEndPoints,proxyEndPoints/virtualhosts,deploymentInfo' },
191
- expect: [200, 404],
192
- withStatus: true
193
- });
194
- if (status === 404) return null;
195
- let defaultHost = null;
196
- if (!list(list(data?.proxyEndPoints)[0]?.virtualhosts).length) {
197
- defaultHost = list(await apim('GET', '/VirtualHosts', { query: { $filter: 'isDefault eq true' } }))[0] ?? null;
198
- }
199
- const url = runtimeUrl(data, defaultHost);
200
- return { ...withoutSecrets(entity(data)), runtime_url: url };
201
- }
202
-
203
- async function exportBundle(name) {
204
- const { status, data } = await client.request('GET', APIM_TRANSPORT, {
205
- query: { name },
206
- accept: 'application/zip, application/octet-stream',
207
- responseType: 'bytes',
208
- expect: [200, 404],
209
- withStatus: true
210
- });
211
- if (status === 404) return null;
212
- try {
213
- return readBundle(data);
214
- } catch {
215
- throw new IntegrationError(`API proxy ${name} export is not a readable zip archive`);
216
- }
217
- }
214
+ const getProxy = name => readApiProxy(client, name);
215
+ const exportBundle = name => exportApiProxyBundle(client, name);
218
216
 
219
217
  async function requireBundle(name, missingHint = '') {
220
218
  const files = await exportBundle(name);
@@ -222,27 +220,10 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
222
220
  return files;
223
221
  }
224
222
 
225
- /** Transport.svc import (creates or replaces and deploys), then wait briefly for the read-back. */
226
- async function importBundle(name, bytes) {
227
- await client.request('POST', APIM_TRANSPORT, {
228
- body: Buffer.from(bytes).toString('base64'),
229
- accept: 'application/zip',
230
- responseType: 'text',
231
- expect: [200, 201]
232
- });
233
- let proxy = null;
234
- for (let attempt = 0; attempt < 4; attempt += 1) {
235
- if (attempt) await sleep(readBackDelayMs);
236
- proxy = await getProxy(name);
237
- if (proxy) break;
238
- }
239
- const result = { name, runtime_url: proxy?.runtime_url ?? null, state: proxy?.state ?? null };
240
- if (!proxy) result.note = 'Proxy imported; APIM read-back is delayed — call apim_get_api_proxy shortly.';
241
- return result;
242
- }
223
+ const importBundle = (name, bytes) => importApiProxyBundle(client, name, bytes, { readBackDelayMs });
243
224
 
244
225
  async function requireProvider(target) {
245
- if (target.provider && !(await exists(`/APIProviders(${key(target.provider)})`))) {
226
+ if (target.provider && !(await exists('/APIProviders', target.provider))) {
246
227
  throw new IntegrationError(`API provider ${target.provider} does not exist; create it with apim_create_api_provider`);
247
228
  }
248
229
  return target.provider ? { provider: target.provider, relativePath: target.relative_path } : { url: target.url };
@@ -251,13 +232,13 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
251
232
  async function getProduct(name) {
252
233
  const { status, data } = await apim('GET', `/APIProducts(${key(name)})`, { query: { $expand: 'apiProxies' }, expect: [200, 404], withStatus: true });
253
234
  if (status === 404) return null;
254
- const { apiProxies, ...product } = entity(data);
255
- return { ...product, api_proxies: list(apiProxies).map(proxy => proxy?.name).filter(Boolean) };
235
+ const { apiProxies, ...product } = requireNamedMetadata(entity(data), name, 'API product');
236
+ return { ...pick(product, PRODUCT_PUT_FIELDS), api_proxies: list(apiProxies).map(proxy => proxy?.name).filter(Boolean) };
256
237
  }
257
238
 
258
239
  async function getKeyValueMap(name) {
259
240
  const { status, data } = await apim('GET', `/KeyMapEntries(${key(name)})`, { query: { $expand: 'keyMapEntryValues' }, expect: [200, 404], withStatus: true });
260
- return status === 404 ? null : entity(data);
241
+ return status === 404 ? null : requireNamedMetadata(entity(data), name, 'Key value map');
261
242
  }
262
243
 
263
244
  registerODataReader(register, {
@@ -265,6 +246,8 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
265
246
  title: 'Read APIM OData API',
266
247
  service: 'API Management Management.svc',
267
248
  request: apim,
249
+ client,
250
+ root: APIM_BASE,
268
251
  examples: '/Applications, /Developers, /CertificateStoreReferences, /APIResources with $filter'
269
252
  });
270
253
 
@@ -272,11 +255,13 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
272
255
 
273
256
  register('apim_list_api_providers', 'List API providers',
274
257
  'List API Management API providers (connection metadata only; credential fields are never selected).',
275
- { top: top() },
258
+ { top: top(), cursor: cursorSchema },
276
259
  READ_ONLY_ANNOTATIONS,
277
- async ({ top }) => withoutSecrets(entity(list(await apim('GET', '/APIProviders', {
278
- query: { $top: top, $select: 'name,title,description,destType,host,port,useSSL,isOnPremise,cloudConnectorLocation,pathPrefix' }
279
- })))));
260
+ async args => collections.page({
261
+ ...args, path: '/APIProviders', orderBy: 'name asc', identity: provider => provider.name,
262
+ query: { $select: 'name,title,description,destType,host,port,useSSL,isOnPremise,cloudConnectorLocation,pathPrefix' },
263
+ project: provider => pick(provider, ['name', 'title', 'description', 'destType', 'host', 'port', 'useSSL', 'isOnPremise', 'cloudConnectorLocation', 'pathPrefix'])
264
+ }));
280
265
 
281
266
  register('apim_get_api_provider', 'Get API provider',
282
267
  'Read one API provider; credential properties are removed before returning.',
@@ -334,11 +319,19 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
334
319
  if (args.path_prefix) body.pathPrefix = args.path_prefix;
335
320
  await apim('POST', '/APIProviders', { json: body, expect: [201] });
336
321
  if (args.sap_client) await writeSapClient(args.name, args.sap_client);
337
- return {
322
+ const result = {
338
323
  created: true,
339
- provider: await getProvider(args.name),
340
- note: 'APIM reads may lag about 20 seconds after a create; re-read with apim_get_api_provider if provider is null.'
324
+ name: args.name,
325
+ provider: null,
326
+ note: 'Provider created. APIM reads may lag; re-read with apim_get_api_provider if provider is null. Do not repeat creation.'
341
327
  };
328
+ try {
329
+ result.provider = await getProvider(args.name);
330
+ } catch (error) {
331
+ if (!(error instanceof IntegrationError)) throw error;
332
+ result.read_back_error = error.message;
333
+ }
334
+ return result;
342
335
  });
343
336
 
344
337
  register('apim_update_api_provider', 'Update API provider',
@@ -396,11 +389,13 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
396
389
 
397
390
  register('apim_list_api_proxies', 'List API proxies',
398
391
  'List API Management API proxies (name, title, service type, state, version, provider).',
399
- { top: top() },
392
+ { top: top(), cursor: cursorSchema },
400
393
  READ_ONLY_ANNOTATIONS,
401
- async ({ top }) => entity(list(await apim('GET', '/APIProxies', {
402
- query: { $top: top, $select: 'name,title,description,service_code,state,version,provider_name' }
403
- }))));
394
+ async args => collections.page({
395
+ ...args, path: '/APIProxies', orderBy: 'name asc', identity: proxy => proxy.name,
396
+ query: { $select: 'name,title,description,service_code,state,version,provider_name' },
397
+ project: proxy => pick(proxy, ['name', 'title', 'description', 'service_code', 'state', 'version', 'provider_name'])
398
+ }));
404
399
 
405
400
  register('apim_get_api_proxy', 'Get API proxy',
406
401
  'Read one API proxy with its proxy/target endpoints, virtual hosts and deployment info, plus the computed runtime_url.',
@@ -409,7 +404,7 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
409
404
  async ({ name }) => {
410
405
  const proxy = await getProxy(name);
411
406
  if (!proxy) throw new IntegrationError(`API proxy ${name} does not exist`);
412
- return proxy;
407
+ return safeMetadata(withoutSecrets(redactSecrets(requireNamedMetadata(proxy, name, 'API proxy'))));
413
408
  });
414
409
 
415
410
  register('apim_export_api_proxy', 'Export API proxy bundle',
@@ -420,20 +415,22 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
420
415
 
421
416
  register('apim_list_virtual_hosts', 'List virtual hosts',
422
417
  'List API Management virtual hosts (host, port, SSL, default flag).',
423
- {},
418
+ { top: top(), cursor: cursorSchema },
424
419
  READ_ONLY_ANNOTATIONS,
425
- async () => list(await apim('GET', '/VirtualHosts'))
426
- .map(host => pick(host, ['id', 'name', 'virtual_host', 'virtual_port', 'isSSL', 'isDefault'])));
420
+ async args => collections.page({
421
+ ...args, path: '/VirtualHosts', mode: 'local',
422
+ project: host => pick(host, ['id', 'name', 'virtual_host', 'virtual_port', 'isSSL', 'isDefault'])
423
+ }));
427
424
 
428
425
  register('apim_create_api_proxy', 'Create API proxy',
429
- 'Create and deploy an API proxy (Transport.svc import) for an existing API provider or a fixed https URL; for OData provider targets relative_path must be a /sap/opu/odata(4)/ service path. Returns the runtime_url. Requires confirmation exactly equal to CREATE API PROXY <name>.',
430
- proxyInput,
426
+ 'Create and deploy an API proxy (Transport.svc import) for an existing API provider or a fixed https URL; for OData provider targets relative_path must be a /sap/opu/odata(4)/ service path. Optional policies use the same templates as apim_attach_api_proxy_policies and are included before the first deployment, avoiding an unsecured revision. Returns the runtime_url. Requires confirmation exactly equal to CREATE API PROXY <name>.',
427
+ proxyInput.safeExtend({ policies: z.array(policySpec).min(1).max(10).optional() }),
431
428
  CREATE_ANNOTATIONS,
432
429
  async args => {
433
430
  requireConfirmation(args.confirmation, `CREATE API PROXY ${args.name}`);
434
- if (await exists(`/APIProxies(${key(args.name)})`)) throw new IntegrationError(`API proxy ${args.name} already exists; use apim_update_api_proxy`);
431
+ if (await exists('/APIProxies', args.name)) throw new IntegrationError(`API proxy ${args.name} already exists; use apim_update_api_proxy`);
435
432
  const target = await requireProvider(args.target);
436
- const bundle = buildApiProxyBundle({
433
+ let bundle = buildApiProxyBundle({
437
434
  name: args.name,
438
435
  title: args.title ?? args.name,
439
436
  description: args.description ?? '',
@@ -441,6 +438,11 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
441
438
  serviceType: args.service_type,
442
439
  target
443
440
  });
441
+ if (args.policies) {
442
+ const files = readBundle(bundle);
443
+ attachPolicies(files, args.name, args.policies);
444
+ bundle = writeBundle(files);
445
+ }
444
446
  return { imported: true, base_path: args.base_path, ...(await importBundle(args.name, bundle)) };
445
447
  });
446
448
 
@@ -459,33 +461,80 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
459
461
  basePath: args.base_path,
460
462
  target
461
463
  });
464
+ const deployment = await importBundle(args.name, writeBundle(files));
462
465
  return {
463
- updated: true,
464
- imported: true,
466
+ updated: deployment.content_verified,
465
467
  base_path: args.base_path,
466
468
  preserved_policies: preservedPolicies,
467
- ...(await importBundle(args.name, writeBundle(files)))
469
+ ...deployment
468
470
  };
469
471
  });
470
472
 
471
473
  register('apim_attach_api_proxy_policies', 'Attach API proxy policies',
472
- 'Add or replace policies on an existing API proxy from templates (verify_api_key, quota, spike_arrest, cors, assign_headers, oauth_verify_access_token, ip_allow, response_cache, basic_auth_from_kvm, raise_fault, javascript, custom): exports the bundle, writes Policy/<name>.xml, registers it, appends a flow step (sequence = last + 1) and re-imports, which deploys a new revision. Re-attaching a name replaces its XML without duplicating its step. response_cache always steps on proxy preFlow request and postFlow response; basic_auth_from_kvm adds <name>_kvm (KVM Get into private.<name>.user/password) then <name> (BasicAuthentication Encode into request.header.Authorization), default target preFlow request. Requires confirmation exactly equal to UPDATE API PROXY POLICIES <name>.',
474
+ 'Add or replace policies from templates (verify_api_key, quota, spike_arrest, cors, assign_headers, oauth_verify_access_token, ip_allow, response_cache, basic_auth_from_kvm, raise_fault, javascript, custom). Export, replace policy XML, register, append new steps and import; this replaces the draft, imports a new revision and requests deployment. Re-attaching replaces policy XML without duplicating or moving its exact supported assignment; a supplied condition updates that assignment, while omission preserves its condition. Use apim_edit_policy_steps for moves, removals or ordering. response_cache steps on proxy preFlow request and postFlow response; basic_auth_from_kvm adds <name>_kvm then <name>, default target preFlow request. Requires confirmation exactly equal to UPDATE API PROXY POLICIES <name>.',
473
475
  z.object({ name: apimName, policies: z.array(policySpec).min(1).max(10), confirmation }).strict(),
474
476
  OVERWRITE_ANNOTATIONS,
475
477
  async args => {
476
478
  requireConfirmation(args.confirmation, `UPDATE API PROXY POLICIES ${args.name}`);
477
479
  const files = await requireBundle(args.name);
478
480
  const { policies, stepsAdded } = attachPolicies(files, args.name, args.policies);
479
- return { updated: true, policies, steps_added: stepsAdded, ...(await importBundle(args.name, writeBundle(files))) };
481
+ const deployment = await importBundle(args.name, writeBundle(files));
482
+ return { updated: deployment.content_verified, policies, steps_added: stepsAdded, ...deployment };
483
+ });
484
+
485
+ register('apim_get_api_documentation', 'Get API documentation',
486
+ 'Read registered OpenAPI JSON as a safe summary of version, security schemes and path/method inventory (100 operations per page; continue with next_operation_offset), optionally selecting one bounded operation. Examples, raw schemas and arbitrary extension payloads are never returned.',
487
+ z.object({
488
+ name: apimName,
489
+ path: z.string().min(1).max(2048).optional(),
490
+ method: z.enum(DOCUMENTATION_METHODS).optional(),
491
+ operation_offset: z.number().int().min(0).max(1_000_000).default(0)
492
+ }).strict().superRefine((value, context) => {
493
+ if ((value.path === undefined) !== (value.method === undefined)) context.addIssue({ code: 'custom', message: 'path and method must be supplied together' });
494
+ }),
495
+ READ_ONLY_ANNOTATIONS,
496
+ async ({ name, path, method, operation_offset }) => {
497
+ const document = bundleDocumentation(await requireBundle(name), name);
498
+ return { name, documentation_path: document.path, ...summarizeApiDocumentation(document.text, { path, method, operationOffset: operation_offset }) };
499
+ });
500
+
501
+ register('apim_update_api_documentation', 'Update API documentation',
502
+ 'Replace the registered English SWAGGER_JSON documentation of an existing proxy with self-contained OpenAPI 2.0 or 3.0/3.1 JSON (maximum 1 MiB). Its explicit HTTPS public runtime URL must match the named proxy; external references and ambiguous layouts are rejected. Exports fresh, changes only documentation bytes, imports/redeploys and reads the decoded document back. Requires confirmation exactly equal to UPDATE API DOCUMENTATION <name>.',
503
+ z.object({
504
+ name: apimName,
505
+ openapi_json: z.string().min(2).max(MAX_DOCUMENTATION_BYTES).refine(value => Buffer.byteLength(value, 'utf8') <= MAX_DOCUMENTATION_BYTES, 'must not exceed 1 MiB'),
506
+ confirmation
507
+ }).strict(),
508
+ OVERWRITE_ANNOTATIONS,
509
+ async ({ name, openapi_json, confirmation: approved }) => {
510
+ requireConfirmation(approved, `UPDATE API DOCUMENTATION ${name}`);
511
+ parseApiDocumentation(openapi_json);
512
+ const proxy = await getProxy(name);
513
+ if (!proxy) throw new IntegrationError(`API proxy ${name} does not exist`);
514
+ if (!proxy.runtime_url) throw new IntegrationError(`API proxy ${name} has no resolved public runtime URL`);
515
+ parseApiDocumentation(openapi_json, proxy.runtime_url);
516
+ const files = await requireBundle(name);
517
+ const { path } = patchBundleDocumentation(files, name, openapi_json);
518
+ const deployment = await importBundle(name, writeBundle(files));
519
+ const summary = summarizeApiDocumentation(openapi_json);
520
+ return {
521
+ updated: deployment.content_verified, ...deployment, documentation_path: path,
522
+ title: summary.title, version: summary.version, openapi_version: summary.openapi_version,
523
+ submitted_sha256: summary.sha256, submitted_bytes: summary.bytes,
524
+ path_count: summary.path_count, operation_count: summary.operation_count
525
+ };
480
526
  });
481
527
 
482
528
  // ---------------------------------------------------------------- products
483
529
 
484
530
  register('apim_list_api_products', 'List API products',
485
531
  'List API Management products (publish state, quota, scope).',
486
- { top: top() },
532
+ { top: top(), cursor: cursorSchema },
487
533
  READ_ONLY_ANNOTATIONS,
488
- async ({ top }) => entity(list(await apim('GET', '/APIProducts', { query: { $top: top, $select: PRODUCT_FIELDS.join(',') } }))));
534
+ async args => collections.page({
535
+ ...args, path: '/APIProducts', orderBy: 'name asc', identity: product => product.name,
536
+ query: { $select: PRODUCT_FIELDS.join(',') }, project: product => pick(product, PRODUCT_FIELDS)
537
+ }));
489
538
 
490
539
  register('apim_get_api_product', 'Get API product',
491
540
  'Read one API product with the names of its API proxies (api_proxies).',
@@ -512,9 +561,9 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
512
561
  CREATE_ANNOTATIONS,
513
562
  async args => {
514
563
  requireConfirmation(args.confirmation, `CREATE API PRODUCT ${args.name}`);
515
- if (await exists(`/APIProducts(${key(args.name)})`)) throw new IntegrationError(`API product ${args.name} already exists; use apim_update_api_product`);
564
+ if (await exists('/APIProducts', args.name)) throw new IntegrationError(`API product ${args.name} already exists; use apim_update_api_product`);
516
565
  for (const proxy of args.proxies) {
517
- if (!(await exists(`/APIProxies(${key(proxy)})`))) throw new IntegrationError(`API proxy ${proxy} does not exist; nothing was created`);
566
+ if (!(await exists('/APIProxies', proxy))) throw new IntegrationError(`API proxy ${proxy} does not exist; nothing was created`);
518
567
  }
519
568
  const body = {
520
569
  name: args.name,
@@ -523,7 +572,7 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
523
572
  version: '1',
524
573
  scope: args.scope ?? '',
525
574
  isPublished: args.publish,
526
- ...(args.publish ? { status_code: 'PUBLISHED' } : {}),
575
+ status_code: args.publish ? 'PUBLISHED' : 'DRAFT',
527
576
  ...quotaFields(args.quota),
528
577
  apiProxies: args.proxies.map(proxy => ({ __metadata: { uri: `APIProxies(name=${odataKey(proxy)})` } }))
529
578
  };
@@ -553,8 +602,50 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
553
602
  if (args.scope !== undefined) body.scope = args.scope;
554
603
  if (args.quota !== undefined) Object.assign(body, quotaFields(args.quota));
555
604
  if (args.publish === true) Object.assign(body, { isPublished: true, status_code: 'PUBLISHED' });
556
- if (args.publish === false) body.isPublished = false;
557
- await apim('PUT', `/APIProducts(${key(args.name)})`, { json: body, expect: [200, 204] });
605
+ if (args.publish === false) Object.assign(body, { isPublished: false, status_code: 'DRAFT' });
606
+ const batch = `batch_${randomUUID()}`;
607
+ const changeset = `changeset_${randomUUID()}`;
608
+ const payload = [
609
+ `--${batch}`,
610
+ `Content-Type: multipart/mixed; boundary=${changeset}`,
611
+ '',
612
+ `--${changeset}`,
613
+ 'Content-Type: application/http',
614
+ 'Content-Transfer-Encoding: binary',
615
+ 'Content-ID: 1',
616
+ '',
617
+ `PUT APIProducts(name=${odataKey(args.name)}) HTTP/1.1`,
618
+ 'Content-Type: application/json',
619
+ 'Accept: application/json',
620
+ '',
621
+ JSON.stringify(body),
622
+ `--${changeset}--`,
623
+ `--${batch}--`,
624
+ ''
625
+ ].join('\r\n');
626
+ const response = await apim('POST', '/$batch', {
627
+ body: payload,
628
+ contentType: `multipart/mixed; boundary=${batch}`,
629
+ accept: 'multipart/mixed',
630
+ responseType: 'text',
631
+ expect: [200, 202]
632
+ });
633
+ const statuses = [...String(response).matchAll(/(?:^|\r?\n)HTTP\/1\.[01] (\d{3})(?:[ \r\n])/g)].map(match => Number(match[1]));
634
+ if (statuses.length !== 1 || ![200, 204].includes(statuses[0])) {
635
+ throw new IntegrationError(statuses.length
636
+ ? `API product update changeset failed (HTTP ${statuses.join(', ')})`
637
+ : 'API product update returned no embedded HTTP result; update is not confirmed');
638
+ }
639
+ const inner = String(response).slice(String(response).search(/HTTP\/1\.[01] /));
640
+ const headerEnd = /\r?\n\r?\n/.exec(inner);
641
+ const responseBody = headerEnd ? inner.slice(headerEnd.index + headerEnd[0].length).split(/\r?\n--/)[0].trim() : '';
642
+ if (responseBody) {
643
+ let decoded;
644
+ try { decoded = JSON.parse(responseBody); } catch {
645
+ throw new IntegrationError('API product update returned an unreadable embedded response; update is not confirmed');
646
+ }
647
+ if (decoded?.error) throw new IntegrationError('API product update changeset returned an embedded OData error');
648
+ }
558
649
  return { updated: true, product: await getProduct(args.name) };
559
650
  });
560
651
 
@@ -562,21 +653,23 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
562
653
 
563
654
  register('apim_list_key_value_maps', 'List key value maps',
564
655
  'List environment key value maps (name, encrypted, scope); values are not read.',
565
- { top: top() },
656
+ { top: top(), cursor: cursorSchema },
566
657
  READ_ONLY_ANNOTATIONS,
567
- async ({ top }) => entity(list(await apim('GET', '/KeyMapEntries', { query: { $top: top, $select: 'name,encrypted,scope' } }))));
658
+ async args => collections.page({
659
+ ...args, path: '/KeyMapEntries', orderBy: 'name asc', identity: map => map.name,
660
+ query: { $select: 'name,encrypted,scope' }, project: map => pick(map, ['name', 'encrypted', 'scope'])
661
+ }));
568
662
 
569
663
  register('apim_get_key_value_map', 'Get key value map',
570
- 'Read one key value map with its entry names; values of encrypted maps are returned as [redacted].',
664
+ 'Read one key value map with its entry names; all values are write-only and returned as [redacted], regardless of encryption.',
571
665
  { name: apimName },
572
666
  READ_ONLY_ANNOTATIONS,
573
667
  async ({ name }) => {
574
668
  const map = await getKeyValueMap(name);
575
669
  if (!map) throw new IntegrationError(`Key value map ${name} does not exist`);
576
- const encrypted = map.encrypted !== false && map.encrypted !== 'false';
577
670
  return {
578
671
  ...pick(map, ['name', 'encrypted', 'scope']),
579
- entries: list(map.keyMapEntryValues).map(entry => (encrypted ? { name: entry.name, value: REDACTED } : pick(entry, ['name', 'value'])))
672
+ entries: list(map.keyMapEntryValues).map(entry => ({ name: entry.name, value: REDACTED }))
580
673
  };
581
674
  });
582
675
 
@@ -586,7 +679,7 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
586
679
  CREATE_ANNOTATIONS,
587
680
  async args => {
588
681
  requireConfirmation(args.confirmation, `CREATE KEY VALUE MAP ${args.name}`);
589
- if (await exists(`/KeyMapEntries(${key(args.name)})`)) throw new IntegrationError(`Key value map ${args.name} already exists; use apim_set_key_value_map_entries`);
682
+ if (await getKeyValueMap(args.name)) throw new IntegrationError(`Key value map ${args.name} already exists; use apim_set_key_value_map_entries`);
590
683
  try {
591
684
  await apim('POST', '/KeyMapEntries', {
592
685
  json: {
@@ -1,8 +1,8 @@
1
1
  // Integration Suite credentials from the logged-in Cloud Foundry space: find the Process Integration
2
- // Runtime (plan api) and API Management (plan apiportal-apiaccess) instances, reuse or create one
3
- // named service key per instance, and optionally create a dedicated instance with the roles the tools need.
2
+ // Runtime (plan api), API Management (plan apiportal-apiaccess) and Developer Hub (devportal-apiaccess) instances;
3
+ // reuse or create a named service key, and optionally create a dedicated instance with the roles the tools need.
4
4
  import { execFile as nodeExecFile } from 'node:child_process';
5
- import { cfJson, runCf, serviceInstances, serviceKeyCredentials } from './cf-destination.mjs';
5
+ import { cfJson, getCloudFoundryTarget, runCf, serviceInstances, serviceKeyCredentials } from './cf-destination.mjs';
6
6
 
7
7
  // Not the toolkit's destination-key prefix: the destination orphan sweep must not offer to delete these keys.
8
8
  export const INTEGRATION_KEY_NAME = 'sap-ai-dev-integration';
@@ -27,14 +27,22 @@ export const INTEGRATION_CF_SERVICES = Object.freeze({
27
27
  label: 'API Management, API portal',
28
28
  dedicatedInstance: 'sap-ai-dev-integration-apim',
29
29
  parameters: Object.freeze({ role: 'APIPortal.Administrator' })
30
+ }),
31
+ DEVHUB: Object.freeze({
32
+ // SAP-samples/btp-service-metadata v1 inventory identifies this Developer Hub CF service offering.
33
+ offering: 'apimanagement-devportal',
34
+ plan: 'devportal-apiaccess',
35
+ label: 'API Management, Developer Hub',
36
+ dedicatedInstance: 'sap-ai-dev-integration-devhub',
37
+ parameters: Object.freeze({ role: 'AuthGroup.API.Admin' })
30
38
  })
31
39
  });
32
40
 
33
- /** Integration Suite instances in the space, by side: { CPI: [{ guid, name }], APIM: [...] }. */
41
+ /** Integration Suite instances in the space, by side: { CPI: [...], APIM: [...], DEVHUB: [...] }. */
34
42
  export async function findIntegrationInstances({ env = process.env, spaceGuid, execFileImpl = nodeExecFile } = {}) {
35
43
  const options = { env, execFileImpl };
36
44
  const plans = new Map();
37
- const found = { CPI: [], APIM: [] };
45
+ const found = { CPI: [], APIM: [], DEVHUB: [] };
38
46
  for (const instance of await serviceInstances(spaceGuid, options)) {
39
47
  const planGuid = instance?.relationships?.service_plan?.data?.guid;
40
48
  if (!planGuid || !instance?.name) continue;
@@ -66,9 +74,40 @@ export async function integrationServiceKey(instanceName, { env = process.env, e
66
74
  return { keyName: INTEGRATION_KEY_NAME, created: true, credentials: await serviceKeyCredentials(instanceName, INTEGRATION_KEY_NAME, 'Integration Suite', options) };
67
75
  }
68
76
 
69
- /** Create the dedicated instance for a side with the roles the tools need; returns { guid?, name }. */
77
+ /** Create a dedicated instance only from a plan visible in the targeted CF space. */
70
78
  export async function createIntegrationInstance(side, { env = process.env, execFileImpl = nodeExecFile } = {}) {
71
79
  const service = INTEGRATION_CF_SERVICES[side];
72
- await runCf(['create-service', service.offering, service.plan, service.dedicatedInstance, '-c', JSON.stringify(service.parameters), '--wait'], { env, execFileImpl });
80
+ const options = { env, execFileImpl };
81
+ const target = await getCloudFoundryTarget(options);
82
+ if (!target.available) throw new Error(target.reason);
83
+ const query = `/v3/service_plans?names=${encodeURIComponent(service.plan)}&include=service_offering`;
84
+ const matchingPlans = payload => (payload.resources || []).filter(plan =>
85
+ payload.included?.service_offerings?.some(offering =>
86
+ offering.guid === plan.relationships?.service_offering?.data?.guid && offering.name === service.offering));
87
+ const scoped = await cfJson(`${query}&space_guids=${encodeURIComponent(target.spaceGuid)}&available=true`, options);
88
+ const plans = matchingPlans(scoped);
89
+ if (!plans.length) {
90
+ const catalog = await cfJson(query, options);
91
+ const scopes = [];
92
+ for (const plan of matchingPlans(catalog)) {
93
+ try {
94
+ const visibility = await cfJson(`/v3/service_plans/${encodeURIComponent(plan.guid)}/visibility`, options);
95
+ scopes.push(...(visibility.organizations || []).map(org => org.name || org.guid));
96
+ } catch {
97
+ scopes.push('visibility details unavailable');
98
+ }
99
+ }
100
+ const detail = scopes.length ? ` Catalog organization visibility: ${[...new Set(scopes)].join(', ')}.` : ' No matching plan is visible in the catalog.';
101
+ throw new Error(`${service.offering}/${service.plan} is not available in ${target.orgName} / ${target.spaceName}.${detail} Assign the entitlement to the intended subaccount. No instance was created.`);
102
+ }
103
+ if (plans.length !== 1) throw new Error(`Multiple ${service.offering}/${service.plan} plans are visible in ${target.orgName} / ${target.spaceName}; broker selection is ambiguous. No instance was created.`);
104
+ const offering = scoped.included.service_offerings.find(item => item.guid === plans[0].relationships.service_offering.data.guid);
105
+ const brokerGuid = offering.relationships?.service_broker?.data?.guid;
106
+ if (!brokerGuid) throw new Error('The visible service offering has no broker reference. No instance was created.');
107
+ // Broker names are visible through offering fields even when broker-admin details are hidden.
108
+ const projected = await cfJson(`/v3/service_offerings/${encodeURIComponent(offering.guid)}?fields[service_broker]=guid,name`, options);
109
+ const broker = projected.included?.service_brokers?.find(item => item.guid === brokerGuid);
110
+ if (!broker?.name) throw new Error('The visible service broker has no accessible name. No instance was created.');
111
+ await runCf(['create-service', service.offering, service.plan, service.dedicatedInstance, '-b', broker.name, '-c', JSON.stringify(service.parameters), '--wait'], options);
73
112
  return { name: service.dedicatedInstance };
74
113
  }