sap-ai-dev-toolkit 0.9.9912 → 0.9.9914

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.github/agents/abap-developer.agent.md +1 -1
  2. package/.github/agents/sap-integration-architect.agent.md +38 -0
  3. package/.github/agents/sap-integration-developer.agent.md +31 -0
  4. package/.github/agents/sap-solution-architect.agent.md +1 -0
  5. package/.github/skills/abap-development/SKILL.md +1 -1
  6. package/.github/skills/cds-development/SKILL.md +1 -1
  7. package/.github/skills/integration-suite-delivery/SKILL.md +74 -0
  8. package/.github/skills/sap-integration-architect/SKILL.md +76 -0
  9. package/NOTICE +10 -0
  10. package/README.md +36 -8
  11. package/dist/checksums.txt +5 -5
  12. package/dist/vsp-darwin-arm64 +0 -0
  13. package/dist/vsp-darwin-x64 +0 -0
  14. package/dist/vsp-linux-arm64 +0 -0
  15. package/dist/vsp-linux-x64 +0 -0
  16. package/dist/vsp-win32-x64.exe +0 -0
  17. package/inventory.md +66 -2
  18. package/package.json +5 -2
  19. package/patches/vsp-bas-proxy-auth.patch +88 -7
  20. package/scripts/run-live-integration.mjs +1079 -0
  21. package/src/apim-bundle.mjs +381 -0
  22. package/src/apim-tools.mjs +641 -0
  23. package/src/cpi-tools.mjs +1042 -0
  24. package/src/engineering-tools.mjs +105 -0
  25. package/src/iflow-bundle.mjs +282 -0
  26. package/src/integration-common.mjs +232 -0
  27. package/src/integration-config.mjs +167 -0
  28. package/src/integration-credentials.mjs +65 -0
  29. package/src/integration-http.mjs +266 -0
  30. package/src/integration-server.mjs +125 -0
  31. package/src/integration-templates/https-request-reply-http.iflw +391 -0
  32. package/src/integration-tools.mjs +27 -0
  33. package/src/integration-verify.mjs +118 -0
  34. package/src/launcher.mjs +14 -2
  35. package/src/mcp-config.mjs +18 -3
  36. package/src/setup.mjs +189 -11
  37. package/tools.md +81 -3
@@ -0,0 +1,641 @@
1
+ // MCP tools for SAP API Management (APIM) over the API portal Management.svc / Transport.svc APIs.
2
+ import { z } from 'zod';
3
+ import { attachPolicies, buildApiProxyBundle, bundlePolicyNames, patchProxyBundle } from './apim-bundle.mjs';
4
+ import {
5
+ CREATE_ANNOTATIONS,
6
+ OVERWRITE_ANNOTATIONS,
7
+ READ_ONLY_ANNOTATIONS,
8
+ REDACTED,
9
+ bundleFiles,
10
+ confirmation,
11
+ entity,
12
+ key,
13
+ list,
14
+ pick,
15
+ registerODataReader,
16
+ requireConfirmation,
17
+ secretValue,
18
+ sleep,
19
+ top,
20
+ withoutSecrets
21
+ } from './integration-common.mjs';
22
+ import { IntegrationError, odataKey } from './integration-http.mjs';
23
+ import { readBundle, writeBundle } from './iflow-bundle.mjs';
24
+
25
+ const APIM_BASE = '/apiportal/api/1.0/Management.svc';
26
+ const APIM_TRANSPORT = '/apiportal/api/1.0/Transport.svc/APIProxies';
27
+ const RESERVED_PROVIDER_NAMES = new Set(['DEST_CI', 'APIMGMT_PORTAL', 'ContentCatalog', 'ON_PREM', 'TC']);
28
+
29
+ const apimName = z.string().regex(/^[A-Za-z][A-Za-z0-9_-]{0,99}$/);
30
+ const basePath = z.string().regex(/^\/[A-Za-z0-9_\-/.]{1,200}$/)
31
+ .refine(value => !value.includes('..') && !value.includes('//') && !value.endsWith('/'), 'must not contain .. or // or end with /');
32
+ const SAP_SERVICE_PATH = /^\/sap\/opu\/odata4?\//;
33
+ const relativePath = z.string().max(1024).regex(/^\//, 'must start with /')
34
+ .refine(value => !value.includes('..') && !value.includes('?') && !value.includes('#'), 'must not contain .., ? or #');
35
+
36
+ const proxyTarget = z.union([
37
+ z.object({ provider: apimName, relative_path: relativePath }).strict(),
38
+ z.object({ url: z.string().max(2048).url().regex(/^https:\/\//, 'must be an https URL') }).strict()
39
+ ]);
40
+
41
+ const proxyInput = z.object({
42
+ name: apimName,
43
+ title: z.string().max(255).optional(),
44
+ description: z.string().max(4096).optional()
45
+ .describe('Stored by API Management as HTML: plain-text <, > and & come back HTML-escaped.'),
46
+ base_path: basePath,
47
+ service_type: z.enum(['odata', 'rest']),
48
+ target: proxyTarget,
49
+ confirmation
50
+ }).strict().superRefine((value, context) => {
51
+ if (value.service_type === 'odata' && value.target?.provider && !SAP_SERVICE_PATH.test(value.target.relative_path)) {
52
+ context.addIssue({ code: 'custom', path: ['target', 'relative_path'], message: 'OData provider targets must use a /sap/opu/odata/ or /sap/opu/odata4/ service path' });
53
+ }
54
+ });
55
+
56
+ // ---------------------------------------------------------------- policy templates
57
+
58
+ const POLICY_TYPES = ['AccessControl', 'AssignMessage', 'BasicAuth', 'DecodeJWT', 'ExtractVariable', 'GenerateJWT', 'GenerateSAMLAssertion',
59
+ 'GetOAuthV2Info', 'Javascript', 'JSONThreatProtection', 'KeyValueMapOperations', 'LookupCache', 'OAuthV2', 'PopulateCache', 'PythonScript',
60
+ 'Quota', 'RaiseFault', 'RegularExpressionProtection', 'ResponseCache', 'ServiceCallout', 'SetOAuthV2Info', 'SpikeArrest',
61
+ 'ValidateSAMLAssertion', 'VerifyAPIKey', 'VerifyJWT', 'XMLThreatProtection'];
62
+ const MAX_SCRIPT_BYTES = 64 * 1024;
63
+ const policyName = z.string().regex(/^[A-Za-z][A-Za-z0-9_-]{0,63}$/);
64
+ const variableRef = z.string().regex(/^[A-Za-z][A-Za-z0-9_.-]{0,199}$/, 'must be a flow variable such as request.header.apikey');
65
+ const headerName = z.string().regex(/^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,128}$/, 'must be an HTTP header name');
66
+ const headerValue = z.string().max(1024).regex(/^[^\r\n]*$/, 'must be a single line');
67
+ const timeUnit = z.enum(['minute', 'hour', 'day', 'week', 'month']);
68
+ const kvmEntryName = z.string().min(1).max(255).regex(/^[^\x00-\x1f]+$/, 'must not contain control characters');
69
+ const ipv4Cidr = z.string().regex(/^(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}(?:\/(?:3[0-2]|[12]?\d))?$/, 'must be an IPv4 address with optional /mask');
70
+
71
+ const placement = {
72
+ name: policyName,
73
+ endpoint: z.enum(['proxy', 'target']).optional().describe('Endpoint whose flow gets the step (default proxy; basic_auth_from_kvm defaults to target).'),
74
+ flow: z.enum(['pre', 'post']).optional().describe('preFlow or postFlow (default pre).'),
75
+ phase: z.enum(['request', 'response']).optional().describe('Request or response side of the flow (default request; cors response; assign_headers its message).'),
76
+ condition: z.string().max(1024).optional().describe('Step condition, e.g. request.verb = "GET".')
77
+ };
78
+ const template = (name, params) => z.object({ ...placement, template: z.literal(name), params }).strict();
79
+
80
+ const policySpec = z.discriminatedUnion('template', [
81
+ template('verify_api_key', z.object({ key_ref: variableRef.default('request.header.apikey') }).strict().prefault({})),
82
+ template('quota', z.object({
83
+ count: z.number().int().min(1).max(1_000_000_000),
84
+ interval: z.number().int().min(1).max(1_000_000),
85
+ time_unit: timeUnit
86
+ }).strict()),
87
+ template('spike_arrest', z.object({ rate: z.string().regex(/^\d{1,6}p[sm]$/, 'must look like 30ps or 100pm') }).strict()),
88
+ template('cors', z.object({
89
+ allow_origin: headerValue.default('*'),
90
+ allow_methods: headerValue,
91
+ allow_headers: headerValue,
92
+ expose_headers: headerValue.optional(),
93
+ max_age: z.number().int().min(0).max(31_536_000).optional()
94
+ }).strict()).refine(value => value.phase !== 'request', { path: ['phase'], message: 'cors headers are added to the response; phase must be response' }),
95
+ template('assign_headers', z.object({
96
+ message: z.enum(['request', 'response']),
97
+ set: z.record(headerName, headerValue).default({}),
98
+ remove: z.array(headerName).max(50).default([])
99
+ }).strict().refine(value => Object.keys(value.set).length || value.remove.length, 'set or remove at least one header')
100
+ .refine(value => Object.keys(value.set).length <= 50, 'at most 50 headers can be set'))
101
+ .refine(value => !value.phase || value.phase === value.params.message, { path: ['phase'], message: 'phase must match params.message' }),
102
+ template('oauth_verify_access_token', z.object({}).strict().prefault({})),
103
+ template('ip_allow', z.object({ addresses: z.array(ipv4Cidr).min(1).max(100) }).strict()),
104
+ template('response_cache', z.object({
105
+ timeout_seconds: z.number().int().min(1).max(31_536_000),
106
+ key_fragments: z.array(variableRef).min(1).max(10).optional().describe('Cache key flow variables (default request.uri).')
107
+ }).strict()),
108
+ template('basic_auth_from_kvm', z.object({ kvm: apimName, user_key: kvmEntryName, password_key: kvmEntryName }).strict()),
109
+ template('raise_fault', z.object({
110
+ status_code: z.number().int().min(100).max(599),
111
+ reason_phrase: headerValue.max(128),
112
+ payload: z.string().max(16 * 1024).optional(),
113
+ content_type: z.string().regex(/^[\w.+-]+\/[\w.+-]+(?:;\s*[\w-]+=[\w.-]+)*$/).optional()
114
+ }).strict()),
115
+ template('javascript', z.object({
116
+ file: z.string().regex(/^[A-Za-z][A-Za-z0-9_]{0,63}$/).describe('Script name without .js; stored as FileResource/<file>.js.'),
117
+ script: z.string().min(1).refine(value => Buffer.byteLength(value, 'utf8') <= MAX_SCRIPT_BYTES, 'script must be at most 64 KiB')
118
+ }).strict()),
119
+ template('custom', z.object({
120
+ type: z.enum(POLICY_TYPES),
121
+ xml: z.string().max(MAX_SCRIPT_BYTES)
122
+ .refine(value => value.trimStart().startsWith('<'), 'xml must start with <')
123
+ .refine(value => /xmlns=(["'])http:\/\/www\.sap\.com\/apimgmt\1/.test(value), 'xml must declare xmlns="http://www.sap.com/apimgmt"')
124
+ }).strict())
125
+ ]);
126
+
127
+ // ---------------------------------------------------------------- products, key value maps
128
+
129
+ const quotaInput = z.object({ count: z.number().int().min(1).max(1_000_000_000), interval: z.number().int().min(1).max(1_000_000), time_unit: timeUnit }).strict();
130
+ const PRODUCT_FIELDS = ['name', 'title', 'description', 'version', 'isPublished', 'status_code', 'quotaCount', 'quotaInterval', 'quotaTimeUnit', 'scope'];
131
+ const PRODUCT_PUT_FIELDS = [...PRODUCT_FIELDS, 'shortText', 'isRestricted'];
132
+ const kvmEntries = z.array(z.object({ name: kvmEntryName, value: secretValue }).strict()).min(1).max(100)
133
+ .refine(entries => new Set(entries.map(entry => entry.name)).size === entries.length, 'entry names must be unique');
134
+
135
+ function quotaFields(quota) {
136
+ return quota
137
+ ? { quotaCount: quota.count, quotaInterval: quota.interval, quotaTimeUnit: quota.time_unit }
138
+ : { quotaCount: -99, quotaInterval: -99, quotaTimeUnit: null };
139
+ }
140
+
141
+ /** Rebuild an IntegrationError without any of the given secret values (tenant error text may echo a body). */
142
+ function withoutValues(error, values) {
143
+ if (error?.name !== 'IntegrationError') return new IntegrationError('API Management request failed');
144
+ let message = error.message;
145
+ for (const value of [...values].sort((left, right) => right.length - left.length)) {
146
+ for (const form of new Set([value, JSON.stringify(value).slice(1, -1), encodeURIComponent(value)])) message = message.replaceAll(form, REDACTED);
147
+ }
148
+ return new IntegrationError(message);
149
+ }
150
+
151
+ /** Register apim_* tools for one APIM client. */
152
+ export function registerApimTools(register, client, { readBackDelayMs }) {
153
+ 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;
158
+ }
159
+
160
+ async function getProvider(name) {
161
+ const { status, data } = await apim('GET', `/APIProviders(${key(name)})`, { expect: [200, 404], withStatus: true });
162
+ if (status === 404) return null;
163
+ // $expand=additionalProperties returns an empty list on live tenants; the navigation link is reliable.
164
+ const additional = list(await apim('GET', `/APIProviders(${key(name)})/additionalProperties`))
165
+ .map(entry => pick(entry, ['name', 'value']));
166
+ return withoutSecrets({ ...entity(data), additionalProperties: additional });
167
+ }
168
+
169
+ async function writeSapClient(name, sapClient) {
170
+ await apim('POST', '/APIProviderAdditionalPropertys', {
171
+ json: { entityId: name, name: 'sap-client', value: sapClient },
172
+ expect: [200, 201, 204]
173
+ });
174
+ }
175
+
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
+
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
+ }
218
+
219
+ async function requireBundle(name, missingHint = '') {
220
+ const files = await exportBundle(name);
221
+ if (!files) throw new IntegrationError(`API proxy ${name} does not exist${missingHint}`);
222
+ return files;
223
+ }
224
+
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
+ }
243
+
244
+ async function requireProvider(target) {
245
+ if (target.provider && !(await exists(`/APIProviders(${key(target.provider)})`))) {
246
+ throw new IntegrationError(`API provider ${target.provider} does not exist; create it with apim_create_api_provider`);
247
+ }
248
+ return target.provider ? { provider: target.provider, relativePath: target.relative_path } : { url: target.url };
249
+ }
250
+
251
+ async function getProduct(name) {
252
+ const { status, data } = await apim('GET', `/APIProducts(${key(name)})`, { query: { $expand: 'apiProxies' }, expect: [200, 404], withStatus: true });
253
+ if (status === 404) return null;
254
+ const { apiProxies, ...product } = entity(data);
255
+ return { ...product, api_proxies: list(apiProxies).map(proxy => proxy?.name).filter(Boolean) };
256
+ }
257
+
258
+ async function getKeyValueMap(name) {
259
+ const { status, data } = await apim('GET', `/KeyMapEntries(${key(name)})`, { query: { $expand: 'keyMapEntryValues' }, expect: [200, 404], withStatus: true });
260
+ return status === 404 ? null : entity(data);
261
+ }
262
+
263
+ registerODataReader(register, {
264
+ name: 'apim_odata_get',
265
+ title: 'Read APIM OData API',
266
+ service: 'API Management Management.svc',
267
+ request: apim,
268
+ examples: '/Applications, /Developers, /CertificateStoreReferences, /APIResources with $filter'
269
+ });
270
+
271
+ // ---------------------------------------------------------------- providers
272
+
273
+ register('apim_list_api_providers', 'List API providers',
274
+ 'List API Management API providers (connection metadata only; credential fields are never selected).',
275
+ { top: top() },
276
+ 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
+ })))));
280
+
281
+ register('apim_get_api_provider', 'Get API provider',
282
+ 'Read one API provider; credential properties are removed before returning.',
283
+ { name: apimName },
284
+ READ_ONLY_ANNOTATIONS,
285
+ async ({ name }) => {
286
+ const provider = await getProvider(name);
287
+ if (!provider) throw new IntegrationError(`API provider ${name} does not exist`);
288
+ return provider;
289
+ });
290
+
291
+ register('apim_create_api_provider', 'Create API provider',
292
+ 'Create an Internet or On-Premise (Cloud Connector) API provider. No credentials are sent; backend auth is forwarded per request or configured in the Cloud Connector. Requires confirmation exactly equal to CREATE API PROVIDER <name>.',
293
+ z.object({
294
+ name: apimName,
295
+ title: z.string().max(255).optional(),
296
+ description: z.string().max(4096).optional(),
297
+ type: z.enum(['internet', 'on_premise']),
298
+ host: z.string().regex(/^[A-Za-z0-9.-]{1,253}$/),
299
+ port: z.number().int().min(1).max(65535),
300
+ use_ssl: z.boolean().optional(),
301
+ location_id: z.string().max(2048).optional(),
302
+ sap_client: z.string().regex(/^\d{3}$/).optional(),
303
+ path_prefix: basePath.optional(),
304
+ confirmation
305
+ }).strict().superRefine((value, context) => {
306
+ if (value.type === 'internet' && value.location_id !== undefined) {
307
+ context.addIssue({ code: 'custom', path: ['location_id'], message: 'location_id applies to on_premise providers only' });
308
+ }
309
+ }),
310
+ CREATE_ANNOTATIONS,
311
+ async args => {
312
+ requireConfirmation(args.confirmation, `CREATE API PROVIDER ${args.name}`);
313
+ if (RESERVED_PROVIDER_NAMES.has(args.name) || args.name.startsWith('Apimgmt_rt') || args.name.startsWith('TC_')) {
314
+ throw new IntegrationError(`API provider name ${args.name} is reserved by API Management`);
315
+ }
316
+ // Write format verified on a live tenant: uppercase destType plus authType NONE; omitting authType
317
+ // fails with DEST_CREATION_FAILED_AUTH_ISSUE_MSG, and the read-back forms (Internet/OnPremise/NOAUTH)
318
+ // are rejected on write. A nested additionalProperties insert is not reliably stored, so sap-client
319
+ // is written through the APIProviderAdditionalPropertys entity set.
320
+ const onPremise = args.type === 'on_premise';
321
+ const body = {
322
+ name: args.name,
323
+ title: args.title ?? args.name,
324
+ description: args.description ?? '',
325
+ destType: onPremise ? 'ON_PREMISE' : 'INTERNET',
326
+ host: args.host,
327
+ port: args.port,
328
+ useSSL: args.use_ssl ?? !onPremise,
329
+ isOnPremise: onPremise,
330
+ authType: 'NONE'
331
+ };
332
+ if (onPremise) body.cloudConnectorLocation = args.location_id ?? '';
333
+ else body.trustAll = false;
334
+ if (args.path_prefix) body.pathPrefix = args.path_prefix;
335
+ await apim('POST', '/APIProviders', { json: body, expect: [201] });
336
+ if (args.sap_client) await writeSapClient(args.name, args.sap_client);
337
+ return {
338
+ 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.'
341
+ };
342
+ });
343
+
344
+ register('apim_update_api_provider', 'Update API provider',
345
+ 'Update an existing API provider; omitted fields keep their current values. Providers with stored backend credentials are refused because the update is written with authType NONE. sap_client is added only when given. Requires confirmation exactly equal to UPDATE API PROVIDER <name>.',
346
+ z.object({
347
+ name: apimName,
348
+ title: z.string().max(255).optional(),
349
+ description: z.string().max(4096).optional(),
350
+ type: z.enum(['internet', 'on_premise']).optional(),
351
+ host: z.string().regex(/^[A-Za-z0-9.-]{1,253}$/).optional(),
352
+ port: z.number().int().min(1).max(65535).optional(),
353
+ use_ssl: z.boolean().optional(),
354
+ location_id: z.string().max(2048).optional(),
355
+ sap_client: z.string().regex(/^\d{3}$/).optional(),
356
+ path_prefix: basePath.optional(),
357
+ confirmation
358
+ }).strict(),
359
+ OVERWRITE_ANNOTATIONS,
360
+ async args => {
361
+ requireConfirmation(args.confirmation, `UPDATE API PROVIDER ${args.name}`);
362
+ const { status, data } = await apim('GET', `/APIProviders(${key(args.name)})`, { expect: [200, 404], withStatus: true });
363
+ if (status === 404) throw new IntegrationError(`API provider ${args.name} does not exist; use apim_create_api_provider`);
364
+ const current = entity(data) ?? {};
365
+ if (current.authType && !['NONE', 'NOAUTH'].includes(String(current.authType).toUpperCase())) {
366
+ throw new IntegrationError(`API provider ${args.name} uses authType ${current.authType}; update it in the API Management UI so its credentials are kept`);
367
+ }
368
+ const wasOnPremise = current.isOnPremise === true || /^on_?prem/i.test(String(current.destType ?? ''));
369
+ const onPremise = args.type ? args.type === 'on_premise' : wasOnPremise;
370
+ if (!onPremise && args.location_id !== undefined) throw new IntegrationError('location_id applies to on_premise providers only');
371
+ const typeChanged = onPremise !== wasOnPremise;
372
+ const host = args.host ?? current.host;
373
+ const port = args.port ?? Number(current.port);
374
+ if (!host || !Number.isInteger(port) || port < 1) throw new IntegrationError(`API provider ${args.name} has no host/port; pass host and port`);
375
+ const body = {
376
+ name: args.name,
377
+ title: args.title ?? current.title ?? args.name,
378
+ description: args.description ?? current.description ?? '',
379
+ destType: onPremise ? 'ON_PREMISE' : 'INTERNET',
380
+ host,
381
+ port,
382
+ useSSL: args.use_ssl ?? (typeChanged || typeof current.useSSL !== 'boolean' ? !onPremise : current.useSSL),
383
+ isOnPremise: onPremise,
384
+ authType: 'NONE'
385
+ };
386
+ if (onPremise) body.cloudConnectorLocation = args.location_id ?? (typeChanged ? '' : current.cloudConnectorLocation ?? '');
387
+ else body.trustAll = typeof current.trustAll === 'boolean' && !typeChanged ? current.trustAll : false;
388
+ const pathPrefix = args.path_prefix ?? current.pathPrefix;
389
+ if (pathPrefix) body.pathPrefix = pathPrefix;
390
+ await apim('PUT', `/APIProviders(${key(args.name)})`, { json: body, expect: [200, 204] });
391
+ if (args.sap_client) await writeSapClient(args.name, args.sap_client);
392
+ return { updated: true, provider: await getProvider(args.name) };
393
+ });
394
+
395
+ // ---------------------------------------------------------------- proxies
396
+
397
+ register('apim_list_api_proxies', 'List API proxies',
398
+ 'List API Management API proxies (name, title, service type, state, version, provider).',
399
+ { top: top() },
400
+ 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
+ }))));
404
+
405
+ register('apim_get_api_proxy', 'Get API proxy',
406
+ 'Read one API proxy with its proxy/target endpoints, virtual hosts and deployment info, plus the computed runtime_url.',
407
+ { name: apimName },
408
+ READ_ONLY_ANNOTATIONS,
409
+ async ({ name }) => {
410
+ const proxy = await getProxy(name);
411
+ if (!proxy) throw new IntegrationError(`API proxy ${name} does not exist`);
412
+ return proxy;
413
+ });
414
+
415
+ register('apim_export_api_proxy', 'Export API proxy bundle',
416
+ 'Export an API proxy bundle (Transport.svc) and return its text files: root APIProxy XML, endpoints, Policy/*.xml and FileResource scripts. Pass file_path for one file.',
417
+ { name: apimName, file_path: z.string().max(400).optional() },
418
+ READ_ONLY_ANNOTATIONS,
419
+ async ({ name, file_path: filePath }) => ({ name, ...bundleFiles(await requireBundle(name), filePath) }));
420
+
421
+ register('apim_list_virtual_hosts', 'List virtual hosts',
422
+ 'List API Management virtual hosts (host, port, SSL, default flag).',
423
+ {},
424
+ READ_ONLY_ANNOTATIONS,
425
+ async () => list(await apim('GET', '/VirtualHosts'))
426
+ .map(host => pick(host, ['id', 'name', 'virtual_host', 'virtual_port', 'isSSL', 'isDefault'])));
427
+
428
+ 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,
431
+ CREATE_ANNOTATIONS,
432
+ async args => {
433
+ 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`);
435
+ const target = await requireProvider(args.target);
436
+ const bundle = buildApiProxyBundle({
437
+ name: args.name,
438
+ title: args.title ?? args.name,
439
+ description: args.description ?? '',
440
+ basePath: args.base_path,
441
+ serviceType: args.service_type,
442
+ target
443
+ });
444
+ return { imported: true, base_path: args.base_path, ...(await importBundle(args.name, bundle)) };
445
+ });
446
+
447
+ register('apim_update_api_proxy', 'Update API proxy',
448
+ 'Change an existing API proxy\'s base path, target, service type and (when given) title/description: exports the current bundle, patches those elements in place (policies, flow steps and scripts are kept) and re-imports it, which deploys a new revision. Requires confirmation exactly equal to UPDATE API PROXY <name>.',
449
+ proxyInput,
450
+ OVERWRITE_ANNOTATIONS,
451
+ async args => {
452
+ requireConfirmation(args.confirmation, `UPDATE API PROXY ${args.name}`);
453
+ const target = await requireProvider(args.target);
454
+ const files = await requireBundle(args.name, '; use apim_create_api_proxy');
455
+ const { preservedPolicies } = patchProxyBundle(files, args.name, {
456
+ title: args.title,
457
+ description: args.description,
458
+ serviceType: args.service_type,
459
+ basePath: args.base_path,
460
+ target
461
+ });
462
+ return {
463
+ updated: true,
464
+ imported: true,
465
+ base_path: args.base_path,
466
+ preserved_policies: preservedPolicies,
467
+ ...(await importBundle(args.name, writeBundle(files)))
468
+ };
469
+ });
470
+
471
+ 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>.',
473
+ z.object({ name: apimName, policies: z.array(policySpec).min(1).max(10), confirmation }).strict(),
474
+ OVERWRITE_ANNOTATIONS,
475
+ async args => {
476
+ requireConfirmation(args.confirmation, `UPDATE API PROXY POLICIES ${args.name}`);
477
+ const files = await requireBundle(args.name);
478
+ const { policies, stepsAdded } = attachPolicies(files, args.name, args.policies);
479
+ return { updated: true, policies, steps_added: stepsAdded, ...(await importBundle(args.name, writeBundle(files))) };
480
+ });
481
+
482
+ // ---------------------------------------------------------------- products
483
+
484
+ register('apim_list_api_products', 'List API products',
485
+ 'List API Management products (publish state, quota, scope).',
486
+ { top: top() },
487
+ READ_ONLY_ANNOTATIONS,
488
+ async ({ top }) => entity(list(await apim('GET', '/APIProducts', { query: { $top: top, $select: PRODUCT_FIELDS.join(',') } }))));
489
+
490
+ register('apim_get_api_product', 'Get API product',
491
+ 'Read one API product with the names of its API proxies (api_proxies).',
492
+ { name: apimName },
493
+ READ_ONLY_ANNOTATIONS,
494
+ async ({ name }) => {
495
+ const product = await getProduct(name);
496
+ if (!product) throw new IntegrationError(`API product ${name} does not exist`);
497
+ return product;
498
+ });
499
+
500
+ register('apim_create_api_product', 'Create API product',
501
+ 'Create an API product bundling existing API proxies (membership is fixed at creation; apim_update_api_product changes only its own fields). Every proxy is checked first. publish (default true) publishes it to the developer portal; proxies should be deployed. Product quota is enforced only by proxies with VerifyAPIKey + Quota policies. Requires confirmation exactly equal to CREATE API PRODUCT <name>.',
502
+ z.object({
503
+ name: apimName,
504
+ title: z.string().max(255).optional(),
505
+ description: z.string().max(4096).optional(),
506
+ scope: z.string().max(1024).optional(),
507
+ proxies: z.array(apimName).min(1).max(50).refine(names => new Set(names).size === names.length, 'proxy names must be unique'),
508
+ quota: quotaInput.optional(),
509
+ publish: z.boolean().default(true),
510
+ confirmation
511
+ }).strict(),
512
+ CREATE_ANNOTATIONS,
513
+ async args => {
514
+ 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`);
516
+ 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`);
518
+ }
519
+ const body = {
520
+ name: args.name,
521
+ title: args.title ?? args.name,
522
+ description: args.description ?? '',
523
+ version: '1',
524
+ scope: args.scope ?? '',
525
+ isPublished: args.publish,
526
+ ...(args.publish ? { status_code: 'PUBLISHED' } : {}),
527
+ ...quotaFields(args.quota),
528
+ apiProxies: args.proxies.map(proxy => ({ __metadata: { uri: `APIProxies(name=${odataKey(proxy)})` } }))
529
+ };
530
+ await apim('POST', '/APIProducts', { json: body, expect: [201] });
531
+ return { created: true, product: await getProduct(args.name) };
532
+ });
533
+
534
+ register('apim_update_api_product', 'Update API product',
535
+ 'Update an API product\'s title, description, scope, quota (null removes it) or publish state; omitted fields keep their values and proxy membership is unchanged. Requires confirmation exactly equal to UPDATE API PRODUCT <name>.',
536
+ z.object({
537
+ name: apimName,
538
+ title: z.string().max(255).optional(),
539
+ description: z.string().max(4096).optional(),
540
+ scope: z.string().max(1024).optional(),
541
+ quota: quotaInput.nullable().optional(),
542
+ publish: z.boolean().optional(),
543
+ confirmation
544
+ }).strict(),
545
+ OVERWRITE_ANNOTATIONS,
546
+ async args => {
547
+ requireConfirmation(args.confirmation, `UPDATE API PRODUCT ${args.name}`);
548
+ const { status, data } = await apim('GET', `/APIProducts(${key(args.name)})`, { expect: [200, 404], withStatus: true });
549
+ if (status === 404) throw new IntegrationError(`API product ${args.name} does not exist; use apim_create_api_product`);
550
+ const body = { ...pick(entity(data), PRODUCT_PUT_FIELDS), name: args.name };
551
+ if (args.title !== undefined) body.title = args.title;
552
+ if (args.description !== undefined) body.description = args.description;
553
+ if (args.scope !== undefined) body.scope = args.scope;
554
+ if (args.quota !== undefined) Object.assign(body, quotaFields(args.quota));
555
+ 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] });
558
+ return { updated: true, product: await getProduct(args.name) };
559
+ });
560
+
561
+ // ---------------------------------------------------------------- key value maps
562
+
563
+ register('apim_list_key_value_maps', 'List key value maps',
564
+ 'List environment key value maps (name, encrypted, scope); values are not read.',
565
+ { top: top() },
566
+ READ_ONLY_ANNOTATIONS,
567
+ async ({ top }) => entity(list(await apim('GET', '/KeyMapEntries', { query: { $top: top, $select: 'name,encrypted,scope' } }))));
568
+
569
+ 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].',
571
+ { name: apimName },
572
+ READ_ONLY_ANNOTATIONS,
573
+ async ({ name }) => {
574
+ const map = await getKeyValueMap(name);
575
+ if (!map) throw new IntegrationError(`Key value map ${name} does not exist`);
576
+ const encrypted = map.encrypted !== false && map.encrypted !== 'false';
577
+ return {
578
+ ...pick(map, ['name', 'encrypted', 'scope']),
579
+ entries: list(map.keyMapEntryValues).map(entry => (encrypted ? { name: entry.name, value: REDACTED } : pick(entry, ['name', 'value'])))
580
+ };
581
+ });
582
+
583
+ register('apim_create_key_value_map', 'Create key value map',
584
+ 'Create an environment-scoped key value map (encrypted by default). Values are write-only and never returned. Requires confirmation exactly equal to CREATE KEY VALUE MAP <name>.',
585
+ z.object({ name: apimName, encrypted: z.boolean().default(true), entries: kvmEntries, confirmation }).strict(),
586
+ CREATE_ANNOTATIONS,
587
+ async args => {
588
+ 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`);
590
+ try {
591
+ await apim('POST', '/KeyMapEntries', {
592
+ json: {
593
+ name: args.name,
594
+ encrypted: args.encrypted,
595
+ scope: 'ENV',
596
+ keyMapEntryValues: args.entries.map(entry => ({ name: entry.name, map_name: args.name, value: entry.value }))
597
+ },
598
+ expect: [200, 201]
599
+ });
600
+ } catch (error) {
601
+ throw withoutValues(error, args.entries.map(entry => entry.value));
602
+ }
603
+ return { created: true, name: args.name, encrypted: args.encrypted, entries: args.entries.map(entry => entry.name) };
604
+ });
605
+
606
+ register('apim_set_key_value_map_entries', 'Set key value map entries',
607
+ 'Create or overwrite entries of an existing key value map, one request per entry in order; stops at the first failure and reports updated/created/failed entry names. Values are write-only and never returned. Requires confirmation exactly equal to UPDATE KEY VALUE MAP <name>.',
608
+ z.object({ name: apimName, entries: kvmEntries, confirmation }).strict(),
609
+ OVERWRITE_ANNOTATIONS,
610
+ async args => {
611
+ requireConfirmation(args.confirmation, `UPDATE KEY VALUE MAP ${args.name}`);
612
+ const map = await getKeyValueMap(args.name);
613
+ if (!map) throw new IntegrationError(`Key value map ${args.name} does not exist; use apim_create_key_value_map`);
614
+ const existing = new Set(list(map.keyMapEntryValues).map(entry => entry.name));
615
+ const values = args.entries.map(entry => entry.value);
616
+ const result = { name: args.name, updated: [], created: [] };
617
+ for (const entry of args.entries) {
618
+ const json = { map_name: args.name, name: entry.name, value: entry.value };
619
+ try {
620
+ if (existing.has(entry.name)) {
621
+ await apim('PUT', `/KeyMapEntryValues(map_name=${key(args.name)},name=${key(entry.name)})`, { json, expect: [200, 204] });
622
+ result.updated.push(entry.name);
623
+ } else {
624
+ await apim('POST', '/KeyMapEntryValues', { json, expect: [200, 201, 204] });
625
+ result.created.push(entry.name);
626
+ }
627
+ } catch (error) {
628
+ result.failed = { name: entry.name, error: withoutValues(error, values).message };
629
+ break;
630
+ }
631
+ }
632
+ return result;
633
+ });
634
+ }
635
+
636
+ /** Names of every apim_* tool, in registration order. */
637
+ export function apimToolNames() {
638
+ const names = [];
639
+ registerApimTools(name => names.push(name), null, {});
640
+ return names;
641
+ }