sap-ai-dev-toolkit 0.9.9913 → 0.9.9915
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/agents/sap-integration-architect.agent.md +38 -0
- package/.github/agents/sap-integration-developer.agent.md +31 -0
- package/.github/agents/sap-solution-architect.agent.md +1 -0
- package/.github/skills/integration-suite-delivery/SKILL.md +74 -0
- package/.github/skills/sap-integration-architect/SKILL.md +76 -0
- package/NOTICE +10 -0
- package/README.md +35 -8
- package/dist/checksums.txt +5 -5
- package/dist/vsp-darwin-arm64 +0 -0
- package/dist/vsp-darwin-x64 +0 -0
- package/dist/vsp-linux-arm64 +0 -0
- package/dist/vsp-linux-x64 +0 -0
- package/dist/vsp-win32-x64.exe +0 -0
- package/inventory.md +62 -0
- package/package.json +5 -2
- package/scripts/run-live-integration.mjs +1079 -0
- package/src/apim-bundle.mjs +381 -0
- package/src/apim-tools.mjs +641 -0
- package/src/cf-destination.mjs +5 -5
- package/src/cf-integration.mjs +74 -0
- package/src/cpi-tools.mjs +1042 -0
- package/src/iflow-bundle.mjs +282 -0
- package/src/integration-common.mjs +232 -0
- package/src/integration-config.mjs +167 -0
- package/src/integration-credentials.mjs +65 -0
- package/src/integration-http.mjs +282 -0
- package/src/integration-server.mjs +125 -0
- package/src/integration-templates/https-request-reply-http.iflw +391 -0
- package/src/integration-tools.mjs +27 -0
- package/src/integration-verify.mjs +118 -0
- package/src/launcher.mjs +14 -2
- package/src/mcp-config.mjs +18 -3
- package/src/setup.mjs +310 -11
- package/tools.md +76 -0
|
@@ -0,0 +1,167 @@
|
|
|
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.
|
|
6
|
+
|
|
7
|
+
const SIDES = Object.freeze({
|
|
8
|
+
CPI: Object.freeze({ prefix: 'CPI', defaultVcapService: 'it-rt' }),
|
|
9
|
+
APIM: Object.freeze({ prefix: 'APIM', defaultVcapService: 'apimanagement-apiportal' })
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
|
13
|
+
|
|
14
|
+
function fail(message) {
|
|
15
|
+
throw new Error(message);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function text(value) {
|
|
19
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function validateEndpoint(value, prefix, label) {
|
|
23
|
+
let parsed;
|
|
24
|
+
try {
|
|
25
|
+
parsed = new URL(value);
|
|
26
|
+
} catch {
|
|
27
|
+
fail(`${prefix}_${label} is not a valid URL`);
|
|
28
|
+
}
|
|
29
|
+
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
|
|
30
|
+
fail(`${prefix}_${label} must not contain credentials, a query, or a fragment`);
|
|
31
|
+
}
|
|
32
|
+
const loopback = LOOPBACK_HOSTS.has(parsed.hostname);
|
|
33
|
+
if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback)) {
|
|
34
|
+
fail(`${prefix}_${label} must use https`);
|
|
35
|
+
}
|
|
36
|
+
return parsed;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function finalize(prefix, source, bindingName, raw) {
|
|
40
|
+
const url = validateEndpoint(raw.url, prefix, 'URL').href.replace(/\/+$/, '');
|
|
41
|
+
const token = validateEndpoint(raw.tokenUrl, prefix, 'TOKEN_URL');
|
|
42
|
+
if (!/\/oauth\/token$/.test(token.pathname)) {
|
|
43
|
+
token.pathname = `${token.pathname.replace(/\/+$/, '')}/oauth/token`;
|
|
44
|
+
}
|
|
45
|
+
return Object.freeze({
|
|
46
|
+
source,
|
|
47
|
+
bindingName: bindingName || null,
|
|
48
|
+
url,
|
|
49
|
+
tokenUrl: token.href,
|
|
50
|
+
clientId: raw.clientId,
|
|
51
|
+
clientSecret: raw.clientSecret
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function explicitConfig(env, prefix) {
|
|
56
|
+
const keys = [`${prefix}_URL`, `${prefix}_TOKEN_URL`, `${prefix}_CLIENT_ID`, `${prefix}_CLIENT_SECRET`];
|
|
57
|
+
if (!keys.some(key => env[key] !== undefined && env[key] !== '')) return null;
|
|
58
|
+
const missing = keys.filter(key => !text(env[key]));
|
|
59
|
+
if (missing.length) fail(`Explicit ${prefix} configuration is incomplete: ${missing.join(', ')}`);
|
|
60
|
+
return finalize(prefix, 'environment', null, {
|
|
61
|
+
url: text(env[`${prefix}_URL`]),
|
|
62
|
+
tokenUrl: text(env[`${prefix}_TOKEN_URL`]),
|
|
63
|
+
clientId: text(env[`${prefix}_CLIENT_ID`]),
|
|
64
|
+
clientSecret: text(env[`${prefix}_CLIENT_SECRET`])
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Parse a CPI/APIM service key (nested `oauth` or flat APIM shape) into raw OAuth fields. */
|
|
69
|
+
function parseServiceKey(value, origin) {
|
|
70
|
+
let key = value;
|
|
71
|
+
if (typeof value === 'string') {
|
|
72
|
+
try {
|
|
73
|
+
key = JSON.parse(value);
|
|
74
|
+
} catch {
|
|
75
|
+
fail(`${origin} is not valid JSON`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (!key || typeof key !== 'object' || Array.isArray(key)) fail(`${origin} must be a JSON object`);
|
|
79
|
+
const oauth = key.oauth && typeof key.oauth === 'object' && !Array.isArray(key.oauth) ? key.oauth : key;
|
|
80
|
+
const raw = {
|
|
81
|
+
url: text(oauth.url),
|
|
82
|
+
tokenUrl: text(oauth.tokenurl) || text(oauth.tokenUrl),
|
|
83
|
+
clientId: text(oauth.clientid) || text(oauth.clientId),
|
|
84
|
+
clientSecret: text(oauth.clientsecret) || text(oauth.clientSecret)
|
|
85
|
+
};
|
|
86
|
+
if (!raw.clientSecret && (oauth.certificate || oauth.key || oauth.privateKey || key.certificate || key.key || key.privateKey)) {
|
|
87
|
+
fail(`${origin} is a certificate-based key; create a ClientId/Secret service key`);
|
|
88
|
+
}
|
|
89
|
+
const missing = Object.entries({ url: raw.url, tokenurl: raw.tokenUrl, clientid: raw.clientId, clientsecret: raw.clientSecret })
|
|
90
|
+
.filter(([, entry]) => !entry)
|
|
91
|
+
.map(([name]) => name);
|
|
92
|
+
if (missing.length) fail(`${origin} is missing ${missing.join(', ')}`);
|
|
93
|
+
return raw;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function serviceKeyConfig(env, prefix) {
|
|
97
|
+
const value = env[`${prefix}_SERVICE_KEY`];
|
|
98
|
+
if (value === undefined || value === '') return null;
|
|
99
|
+
return finalize(prefix, 'service-key', null, parseServiceKey(value, `${prefix}_SERVICE_KEY`));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function vcapConfig(env, prefix, defaultVcapService) {
|
|
103
|
+
const raw = env.VCAP_SERVICES;
|
|
104
|
+
const bindingName = text(env[`${prefix}_BINDING`]);
|
|
105
|
+
if (!raw) {
|
|
106
|
+
if (bindingName) fail(`No ${prefix} VCAP_SERVICES binding matches ${prefix}_BINDING`);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
let services;
|
|
110
|
+
try {
|
|
111
|
+
services = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
112
|
+
} catch {
|
|
113
|
+
fail(`${prefix}_VCAP_SERVICE lookup failed: VCAP_SERVICES is not valid JSON`);
|
|
114
|
+
}
|
|
115
|
+
const label = text(env[`${prefix}_VCAP_SERVICE`]) || defaultVcapService;
|
|
116
|
+
const instances = services && typeof services === 'object' && Array.isArray(services[label]) ? services[label] : [];
|
|
117
|
+
let candidates = instances.filter(instance => instance && typeof instance === 'object' && !Array.isArray(instance));
|
|
118
|
+
if (bindingName) {
|
|
119
|
+
candidates = candidates.filter(instance => (instance.name || instance.binding_name) === bindingName);
|
|
120
|
+
if (!candidates.length) fail(`No ${prefix} VCAP_SERVICES binding matches ${prefix}_BINDING`);
|
|
121
|
+
}
|
|
122
|
+
if (!candidates.length) return null;
|
|
123
|
+
if (candidates.length > 1) fail(`Multiple ${prefix} VCAP_SERVICES bindings; set ${prefix}_BINDING`);
|
|
124
|
+
const [instance] = candidates;
|
|
125
|
+
const name = instance.name || instance.binding_name;
|
|
126
|
+
const origin = `${prefix}_VCAP_SERVICE binding credentials`;
|
|
127
|
+
return finalize(prefix, 'vcap-services', typeof name === 'string' ? name : null, parseServiceKey(instance.credentials, origin));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function storedConfig(stored, prefix) {
|
|
131
|
+
const key = stored?.[prefix.toLowerCase()];
|
|
132
|
+
if (key === undefined || key === null) return null;
|
|
133
|
+
return finalize(prefix, 'stored-key', null, parseServiceKey(key, `Stored ${prefix} service key`));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Precedence: explicit variables, *_SERVICE_KEY, VCAP_SERVICES, then the key stored by `sap-ai-dev --setup-integration`.
|
|
137
|
+
function resolveSide(env, { prefix, defaultVcapService }, stored) {
|
|
138
|
+
return explicitConfig(env, prefix)
|
|
139
|
+
?? serviceKeyConfig(env, prefix)
|
|
140
|
+
?? vcapConfig(env, prefix, defaultVcapService)
|
|
141
|
+
?? storedConfig(stored, prefix);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Resolve one side from the environment only (no stored key); null when that side is not configured there. */
|
|
145
|
+
export function resolveIntegrationSideFromEnvironment(env, side) {
|
|
146
|
+
return resolveSide(env, SIDES[side]);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Validate a pasted CPI/APIM service key (JSON text or object); returns the resolved side config. */
|
|
150
|
+
export function parseIntegrationServiceKey(side, value) {
|
|
151
|
+
return finalize(side, 'stored-key', null, parseServiceKey(value, `${side} service key`));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
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.
|
|
157
|
+
*/
|
|
158
|
+
export function resolveIntegrationConfig(env = process.env, { stored } = {}) {
|
|
159
|
+
const cpi = resolveSide(env, SIDES.CPI, stored);
|
|
160
|
+
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.');
|
|
163
|
+
}
|
|
164
|
+
return Object.freeze({ cpi, apim });
|
|
165
|
+
}
|
|
166
|
+
|
|
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)/;
|
|
@@ -0,0 +1,65 @@
|
|
|
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.
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
const SIDES = ['cpi', 'apim'];
|
|
11
|
+
|
|
12
|
+
export function integrationCredentialsPath(env = process.env) {
|
|
13
|
+
const configHome = typeof env.XDG_CONFIG_HOME === 'string' && env.XDG_CONFIG_HOME.trim()
|
|
14
|
+
? env.XDG_CONFIG_HOME.trim()
|
|
15
|
+
: join(env.HOME || env.USERPROFILE || homedir(), '.config');
|
|
16
|
+
return join(configHome, 'sap-ai-dev-toolkit', 'integration-suite.json');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Stored service keys ({ cpi?, apim? }); {} when nothing was stored. */
|
|
20
|
+
export function readStoredIntegrationKeys(env = process.env) {
|
|
21
|
+
const path = integrationCredentialsPath(env);
|
|
22
|
+
let text;
|
|
23
|
+
try {
|
|
24
|
+
text = readFileSync(path, 'utf8');
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error?.code === 'ENOENT') return {};
|
|
27
|
+
throw new Error(`Stored Integration Suite credentials at ${path} could not be read`);
|
|
28
|
+
}
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = JSON.parse(text);
|
|
32
|
+
} catch {
|
|
33
|
+
throw new Error(`Stored Integration Suite credentials at ${path} are not valid JSON; rerun sap-ai-dev --setup-integration`);
|
|
34
|
+
}
|
|
35
|
+
return Object.fromEntries(SIDES.filter(side => parsed?.[side]).map(side => [side, parsed[side]]));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Save one side's verified OAuth client (resolved config) next to the other side's stored key. */
|
|
39
|
+
export async function storeIntegrationKey(side, config, env = process.env) {
|
|
40
|
+
const key = side.toLowerCase();
|
|
41
|
+
if (!SIDES.includes(key)) throw new Error(`Unknown Integration Suite side ${side}`);
|
|
42
|
+
const path = integrationCredentialsPath(env);
|
|
43
|
+
let existing = {};
|
|
44
|
+
try {
|
|
45
|
+
existing = readStoredIntegrationKeys(env);
|
|
46
|
+
} catch {
|
|
47
|
+
// An unreadable or corrupt store is replaced by the verified key.
|
|
48
|
+
}
|
|
49
|
+
const next = {
|
|
50
|
+
...existing,
|
|
51
|
+
[key]: { oauth: { url: config.url, tokenurl: config.tokenUrl, clientid: config.clientId, clientsecret: config.clientSecret } }
|
|
52
|
+
};
|
|
53
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
54
|
+
await chmod(dirname(path), 0o700).catch(() => {});
|
|
55
|
+
const temporary = join(dirname(path), `.integration-suite.${process.pid}.${randomUUID()}.tmp`);
|
|
56
|
+
try {
|
|
57
|
+
await writeFile(temporary, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
58
|
+
await rename(temporary, path);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
await chmod(path, 0o600).catch(() => {});
|
|
64
|
+
return path;
|
|
65
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// OAuth client-credentials HTTP client for one CPI or APIM tenant.
|
|
2
|
+
// Requests are pinned to the configured tenant origin, bodies are size
|
|
3
|
+
// capped, and token/error responses are never echoed raw.
|
|
4
|
+
import { EnvHttpProxyAgent, fetch as undiciFetch } from 'undici';
|
|
5
|
+
import { diagnosticText } from './redact.mjs';
|
|
6
|
+
|
|
7
|
+
const TOKEN_TIMEOUT_MS = 15_000;
|
|
8
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
9
|
+
const MAX_BYTES = 20 * 1024 * 1024;
|
|
10
|
+
const MAX_TEXT = 2 * 1024 * 1024;
|
|
11
|
+
const DEFAULT_TOKEN_TTL_MS = 10 * 60 * 1000;
|
|
12
|
+
const SERVER_RETRIES = 2;
|
|
13
|
+
const RETRYABLE_STATUS = new Set([500, 502, 503, 504]);
|
|
14
|
+
// Only idempotent methods are retried; POST (create/deploy/import) is never re-sent.
|
|
15
|
+
const RETRYABLE_METHODS = new Set(['GET', 'PUT']);
|
|
16
|
+
|
|
17
|
+
export class IntegrationError extends Error {
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'IntegrationError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Quote an OData string key literal; callers still percent-encode the whole segment. */
|
|
25
|
+
export function odataKey(value) {
|
|
26
|
+
return `'${String(value).replaceAll("'", "''")}'`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Convert OData v2 `/Date(ms)/` values to ISO strings; other values pass through. */
|
|
30
|
+
export function odataDate(value) {
|
|
31
|
+
if (typeof value !== 'string') return value;
|
|
32
|
+
const match = /^\/Date\((-?\d+)(?:[+-]\d{4})?\)\/$/.exec(value);
|
|
33
|
+
return match ? new Date(Number(match[1])).toISOString() : value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function readCapped(response, limit, label) {
|
|
37
|
+
const declared = Number(response.headers.get('content-length'));
|
|
38
|
+
if (Number.isFinite(declared) && declared > limit) {
|
|
39
|
+
await response.body?.cancel().catch(() => {});
|
|
40
|
+
throw new IntegrationError(`${label} response exceeded the safe size limit`);
|
|
41
|
+
}
|
|
42
|
+
if (!response.body) return new Uint8Array(0);
|
|
43
|
+
const chunks = [];
|
|
44
|
+
let total = 0;
|
|
45
|
+
for await (const chunk of response.body) {
|
|
46
|
+
total += chunk.byteLength;
|
|
47
|
+
if (total > limit) {
|
|
48
|
+
await response.body.cancel().catch(() => {});
|
|
49
|
+
throw new IntegrationError(`${label} response exceeded the safe size limit`);
|
|
50
|
+
}
|
|
51
|
+
chunks.push(chunk);
|
|
52
|
+
}
|
|
53
|
+
const bytes = new Uint8Array(total);
|
|
54
|
+
let offset = 0;
|
|
55
|
+
for (const chunk of chunks) {
|
|
56
|
+
bytes.set(chunk, offset);
|
|
57
|
+
offset += chunk.byteLength;
|
|
58
|
+
}
|
|
59
|
+
return bytes;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sapMessage(bodyText) {
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(bodyText);
|
|
65
|
+
const message = parsed?.error?.message;
|
|
66
|
+
if (typeof message?.value === 'string') return message.value;
|
|
67
|
+
if (typeof message === 'string') return message;
|
|
68
|
+
} catch {
|
|
69
|
+
// Not JSON: try an OData XML error, then the raw text prefix.
|
|
70
|
+
}
|
|
71
|
+
const xmlMessage = /<(?:\w+:)?message\b[^>]*>([\s\S]*?)<\/(?:\w+:)?message>/i.exec(bodyText);
|
|
72
|
+
if (xmlMessage) return xmlMessage[1].replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&');
|
|
73
|
+
return bodyText.slice(0, 400);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function setCookiePairs(headers) {
|
|
77
|
+
const values = typeof headers.getSetCookie === 'function'
|
|
78
|
+
? headers.getSetCookie()
|
|
79
|
+
: [headers.get('set-cookie')].filter(Boolean);
|
|
80
|
+
return values.map(value => value.split(';')[0].trim()).filter(Boolean);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// undici flags EnvHttpProxyAgent as experimental with a process warning that lands in the middle of the
|
|
84
|
+
// setup wizard output; only that warning is dropped, and only while the agent is constructed.
|
|
85
|
+
function envProxyAgent(options) {
|
|
86
|
+
const emitWarning = process.emitWarning;
|
|
87
|
+
process.emitWarning = (warning, ...rest) => {
|
|
88
|
+
const code = typeof rest[0] === 'object' ? rest[0]?.code : rest[1];
|
|
89
|
+
if (code === 'UNDICI-EHPA') return;
|
|
90
|
+
emitWarning.call(process, warning, ...rest);
|
|
91
|
+
};
|
|
92
|
+
try {
|
|
93
|
+
return new EnvHttpProxyAgent(options);
|
|
94
|
+
} finally {
|
|
95
|
+
process.emitWarning = emitWarning;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Create a tenant-scoped client using OAuth client credentials. */
|
|
100
|
+
export function createServiceClient({
|
|
101
|
+
label,
|
|
102
|
+
url,
|
|
103
|
+
tokenUrl,
|
|
104
|
+
clientId,
|
|
105
|
+
clientSecret,
|
|
106
|
+
csrfPath = '/',
|
|
107
|
+
env = process.env,
|
|
108
|
+
fetchImpl = undiciFetch,
|
|
109
|
+
now = Date.now,
|
|
110
|
+
retryDelayMs = 500
|
|
111
|
+
}) {
|
|
112
|
+
const origin = new URL(url).origin;
|
|
113
|
+
const httpProxy = env.HTTP_PROXY || env.http_proxy;
|
|
114
|
+
const httpsProxy = env.HTTPS_PROXY || env.https_proxy;
|
|
115
|
+
const dispatcher = httpProxy || httpsProxy
|
|
116
|
+
? envProxyAgent({ httpProxy, httpsProxy, noProxy: env.NO_PROXY || env.no_proxy })
|
|
117
|
+
: undefined;
|
|
118
|
+
const transportOptions = dispatcher ? { dispatcher } : {};
|
|
119
|
+
let cachedToken = null;
|
|
120
|
+
|
|
121
|
+
async function token() {
|
|
122
|
+
if (cachedToken && cachedToken.expiresAt > now()) return cachedToken.value;
|
|
123
|
+
const basic = Buffer.from(`${clientId}:${clientSecret}`, 'utf8').toString('base64');
|
|
124
|
+
let response;
|
|
125
|
+
try {
|
|
126
|
+
response = await fetchImpl(tokenUrl, {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: {
|
|
129
|
+
authorization: `Basic ${basic}`,
|
|
130
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
131
|
+
accept: 'application/json'
|
|
132
|
+
},
|
|
133
|
+
body: 'grant_type=client_credentials',
|
|
134
|
+
redirect: 'manual',
|
|
135
|
+
signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
|
|
136
|
+
...transportOptions
|
|
137
|
+
});
|
|
138
|
+
} catch {
|
|
139
|
+
throw new IntegrationError(`${label} token request failed (network error)`);
|
|
140
|
+
}
|
|
141
|
+
if (response.status < 200 || response.status > 299) {
|
|
142
|
+
await response.body?.cancel().catch(() => {});
|
|
143
|
+
throw new IntegrationError(`${label} token request failed (HTTP ${response.status})`);
|
|
144
|
+
}
|
|
145
|
+
let payload;
|
|
146
|
+
try {
|
|
147
|
+
payload = JSON.parse(new TextDecoder().decode(await readCapped(response, 64 * 1024, `${label} token`)));
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (error instanceof IntegrationError) throw error;
|
|
150
|
+
throw new IntegrationError(`${label} token response is not valid JSON`);
|
|
151
|
+
}
|
|
152
|
+
if (typeof payload?.access_token !== 'string' || !payload.access_token) {
|
|
153
|
+
throw new IntegrationError(`${label} token response has no access_token`);
|
|
154
|
+
}
|
|
155
|
+
const ttlMs = Number.isFinite(Number(payload.expires_in))
|
|
156
|
+
? Math.max(0, (Number(payload.expires_in) - 60) * 1000)
|
|
157
|
+
: DEFAULT_TOKEN_TTL_MS;
|
|
158
|
+
cachedToken = { value: payload.access_token, expiresAt: now() + ttlMs };
|
|
159
|
+
return cachedToken.value;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function send(target, init) {
|
|
163
|
+
try {
|
|
164
|
+
return await fetchImpl(target, {
|
|
165
|
+
...init,
|
|
166
|
+
redirect: 'manual',
|
|
167
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
168
|
+
...transportOptions
|
|
169
|
+
});
|
|
170
|
+
} catch {
|
|
171
|
+
throw new IntegrationError(`${label} ${init.method} ${new URL(target).pathname} failed (network error or timeout)`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function fetchCsrf(bearer) {
|
|
176
|
+
const response = await send(new URL(`${url}${csrfPath}`).href, {
|
|
177
|
+
method: 'GET',
|
|
178
|
+
headers: { authorization: `Bearer ${bearer}`, 'x-csrf-token': 'Fetch', accept: 'application/json' }
|
|
179
|
+
});
|
|
180
|
+
await response.body?.cancel().catch(() => {});
|
|
181
|
+
const csrfToken = response.headers.get('x-csrf-token');
|
|
182
|
+
if (!csrfToken || csrfToken.toLowerCase() === 'required') {
|
|
183
|
+
throw new IntegrationError(`${label} CSRF token fetch failed (HTTP ${response.status})`);
|
|
184
|
+
}
|
|
185
|
+
return { csrfToken, cookies: setCookiePairs(response.headers) };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function request(method, path, {
|
|
189
|
+
query,
|
|
190
|
+
json,
|
|
191
|
+
body,
|
|
192
|
+
contentType,
|
|
193
|
+
accept = 'application/json',
|
|
194
|
+
expect = [200],
|
|
195
|
+
responseType = 'json',
|
|
196
|
+
withStatus = false,
|
|
197
|
+
unwrap = true
|
|
198
|
+
} = {}) {
|
|
199
|
+
const target = new URL(`${url}${path}`);
|
|
200
|
+
for (const [key, value] of Object.entries(query || {})) {
|
|
201
|
+
if (value !== undefined && value !== null) target.searchParams.set(key, String(value));
|
|
202
|
+
}
|
|
203
|
+
// URLSearchParams emits '+' for spaces; OData servers expect %20. Literal '+' is already %2B.
|
|
204
|
+
target.search = target.search.replaceAll('+', '%20');
|
|
205
|
+
if (target.origin !== origin) throw new IntegrationError(`${label} request escaped the configured tenant`);
|
|
206
|
+
|
|
207
|
+
const baseHeaders = { accept };
|
|
208
|
+
let payload;
|
|
209
|
+
if (json !== undefined) {
|
|
210
|
+
baseHeaders['content-type'] = 'application/json';
|
|
211
|
+
payload = JSON.stringify(json);
|
|
212
|
+
} else if (typeof body === 'string') {
|
|
213
|
+
// Bytes, not a string: undici would otherwise add content-type: text/plain.
|
|
214
|
+
payload = Buffer.from(body, 'utf8');
|
|
215
|
+
if (contentType) baseHeaders['content-type'] = contentType;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// One retry each for an expired token (401) and a CSRF challenge. Idempotent GET/PUT are also retried
|
|
219
|
+
// on transient 5xx: CPI intermittently fails bundle downloads and configuration PUTs with HTTP 500.
|
|
220
|
+
let csrf = null;
|
|
221
|
+
let response;
|
|
222
|
+
let authRetried = false;
|
|
223
|
+
let csrfRetried = false;
|
|
224
|
+
let serverRetries = 0;
|
|
225
|
+
for (;;) {
|
|
226
|
+
const headers = { ...baseHeaders, authorization: `Bearer ${await token()}` };
|
|
227
|
+
if (csrf) {
|
|
228
|
+
headers['x-csrf-token'] = csrf.csrfToken;
|
|
229
|
+
if (csrf.cookies.length) headers.cookie = csrf.cookies.join('; ');
|
|
230
|
+
}
|
|
231
|
+
response = await send(target.href, { method, headers, body: payload });
|
|
232
|
+
if (response.status === 401 && !authRetried) {
|
|
233
|
+
authRetried = true;
|
|
234
|
+
await response.body?.cancel().catch(() => {});
|
|
235
|
+
cachedToken = null;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (response.status === 403 && !csrfRetried && (response.headers.get('x-csrf-token') || '').toLowerCase() === 'required') {
|
|
239
|
+
csrfRetried = true;
|
|
240
|
+
await response.body?.cancel().catch(() => {});
|
|
241
|
+
csrf = await fetchCsrf(await token());
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (RETRYABLE_METHODS.has(method) && RETRYABLE_STATUS.has(response.status) && !expect.includes(response.status) && serverRetries < SERVER_RETRIES) {
|
|
245
|
+
serverRetries += 1;
|
|
246
|
+
await response.body?.cancel().catch(() => {});
|
|
247
|
+
await new Promise(resolve => setTimeout(resolve, retryDelayMs * serverRetries));
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (!expect.includes(response.status)) {
|
|
254
|
+
let detail = '';
|
|
255
|
+
try {
|
|
256
|
+
detail = diagnosticText(sapMessage(new TextDecoder().decode(await readCapped(response, MAX_TEXT, label))), 400);
|
|
257
|
+
} catch {
|
|
258
|
+
detail = 'response body unavailable';
|
|
259
|
+
}
|
|
260
|
+
throw new IntegrationError(`${label} ${method} ${target.pathname} failed (HTTP ${response.status}): ${detail}`);
|
|
261
|
+
}
|
|
262
|
+
const status = response.status;
|
|
263
|
+
const wrap = data => (withStatus ? { status, data } : data);
|
|
264
|
+
if (status === 204 || status >= 300) {
|
|
265
|
+
await response.body?.cancel().catch(() => {});
|
|
266
|
+
return wrap(null);
|
|
267
|
+
}
|
|
268
|
+
if (responseType === 'bytes') return wrap(await readCapped(response, MAX_BYTES, label));
|
|
269
|
+
const responseText = new TextDecoder().decode(await readCapped(response, MAX_TEXT, label));
|
|
270
|
+
if (responseType === 'text') return wrap(responseText);
|
|
271
|
+
if (!responseText.trim()) return wrap(null);
|
|
272
|
+
let parsed;
|
|
273
|
+
try {
|
|
274
|
+
parsed = JSON.parse(responseText);
|
|
275
|
+
} catch {
|
|
276
|
+
throw new IntegrationError(`${label} ${method} ${target.pathname} returned a non-JSON response`);
|
|
277
|
+
}
|
|
278
|
+
return wrap(unwrap ? parsed?.d?.results ?? parsed?.d ?? parsed : parsed);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return Object.freeze({ label, request });
|
|
282
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
5
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
|
+
import { resolveIntegrationConfig } from './integration-config.mjs';
|
|
7
|
+
import { integrationCredentialsPath, readStoredIntegrationKeys } from './integration-credentials.mjs';
|
|
8
|
+
import { registerIntegrationTools } from './integration-tools.mjs';
|
|
9
|
+
import { createIntegrationClients, verificationLines, verifyIntegrationClients } from './integration-verify.mjs';
|
|
10
|
+
|
|
11
|
+
const packageMetadata = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
12
|
+
|
|
13
|
+
function sideSummary(config) {
|
|
14
|
+
if (!config) return Object.freeze({ configured: false });
|
|
15
|
+
return Object.freeze({
|
|
16
|
+
configured: true,
|
|
17
|
+
source: config.source,
|
|
18
|
+
binding: config.bindingName,
|
|
19
|
+
host: new URL(config.url).host,
|
|
20
|
+
token_host: new URL(config.tokenUrl).host
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Create an injectable server runtime for tests and the stdio executable. */
|
|
25
|
+
export function createIntegrationServer({ env = process.env, fetchImpl, pollIntervalMs, readBackDelayMs } = {}) {
|
|
26
|
+
const config = resolveIntegrationConfig(env, { stored: readStoredIntegrationKeys(env) });
|
|
27
|
+
const summary = Object.freeze({ cpi: sideSummary(config.cpi), apim: sideSummary(config.apim) });
|
|
28
|
+
const clients = createIntegrationClients(config, { env, fetchImpl });
|
|
29
|
+
const verify = () => verifyIntegrationClients(clients);
|
|
30
|
+
|
|
31
|
+
const server = new McpServer({
|
|
32
|
+
name: 'sap-ai-integration-suite',
|
|
33
|
+
version: packageMetadata.version
|
|
34
|
+
}, { capabilities: { tools: {} } });
|
|
35
|
+
registerIntegrationTools(server, {
|
|
36
|
+
cpi: clients.cpi,
|
|
37
|
+
apim: clients.apim,
|
|
38
|
+
summary,
|
|
39
|
+
verify,
|
|
40
|
+
...(pollIntervalMs !== undefined ? { pollIntervalMs } : {}),
|
|
41
|
+
...(readBackDelayMs !== undefined ? { readBackDelayMs } : {})
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
let closing;
|
|
45
|
+
const close = () => {
|
|
46
|
+
if (!closing) closing = server.close().catch(() => {});
|
|
47
|
+
return closing;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return { server, close, verify, connectionSummary: summary };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function runIntegrationServer({ env = process.env, stdin = process.stdin, stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
54
|
+
if (process.argv.slice(2).some(argument => argument === '--help' || argument === '-h')) {
|
|
55
|
+
stdout.write([
|
|
56
|
+
'Usage: sap-ai-integration',
|
|
57
|
+
'',
|
|
58
|
+
'Starts a stdio MCP server for SAP Integration Suite: Cloud Integration (CPI) packages, iFlows, value mappings,',
|
|
59
|
+
'script collections, security material, Partner Directory and monitoring, and API Management (APIM) providers,',
|
|
60
|
+
'proxies, policies, products and key value maps. Writes are create/update/deploy/undeploy only and need an',
|
|
61
|
+
'exact confirmation string; there are no delete tools. Secrets are write-only and never returned.',
|
|
62
|
+
'',
|
|
63
|
+
'Credentials are read from the MCP host environment (never from mcp.json). Per side, first match wins:',
|
|
64
|
+
' CPI_URL, CPI_TOKEN_URL, CPI_CLIENT_ID, CPI_CLIENT_SECRET explicit CPI OAuth client (all four)',
|
|
65
|
+
' CPI_SERVICE_KEY CPI "api" plan service key JSON',
|
|
66
|
+
' VCAP_SERVICES + CPI_VCAP_SERVICE (default it-rt), CPI_BINDING',
|
|
67
|
+
' APIM_URL, APIM_TOKEN_URL, APIM_CLIENT_ID, APIM_CLIENT_SECRET explicit APIM OAuth client (all four)',
|
|
68
|
+
' APIM_SERVICE_KEY APIM "apiportal-apiaccess" service key JSON',
|
|
69
|
+
' VCAP_SERVICES + APIM_VCAP_SERVICE (default apimanagement-apiportal), APIM_BINDING',
|
|
70
|
+
` ${integrationCredentialsPath(env)}`,
|
|
71
|
+
' keys pasted and verified by sap-ai-dev --setup-integration',
|
|
72
|
+
'HTTP_PROXY, HTTPS_PROXY and NO_PROXY are honored. At least one of CPI or APIM must be configured.',
|
|
73
|
+
'',
|
|
74
|
+
' --check sign in and run one read per tool area, report missing roles, and exit (1 on failure)',
|
|
75
|
+
''
|
|
76
|
+
].join('\n'));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
let runtime;
|
|
80
|
+
try {
|
|
81
|
+
runtime = createIntegrationServer({ env });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
stderr.write(`[sap-ai-integration] ${error.message}\n`);
|
|
84
|
+
process.exitCode = 1;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (process.argv.slice(2).includes('--check')) {
|
|
88
|
+
const report = await runtime.verify();
|
|
89
|
+
const lines = [
|
|
90
|
+
...verificationLines('CPI', runtime.connectionSummary.cpi.host, report.cpi),
|
|
91
|
+
...verificationLines('APIM', runtime.connectionSummary.apim.host, report.apim)
|
|
92
|
+
];
|
|
93
|
+
stdout.write(`${lines.join('\n')}\n`);
|
|
94
|
+
await runtime.close();
|
|
95
|
+
if (!report.ok) process.exitCode = 1;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const transport = new StdioServerTransport(stdin, stdout, { maxBufferSize: 1024 * 1024 });
|
|
100
|
+
let closing;
|
|
101
|
+
const shutdown = () => {
|
|
102
|
+
if (!closing) closing = runtime.close().catch(() => {});
|
|
103
|
+
return closing;
|
|
104
|
+
};
|
|
105
|
+
const onStdinEnd = () => { void shutdown(); };
|
|
106
|
+
const onSignal = signal => {
|
|
107
|
+
void shutdown().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143));
|
|
108
|
+
};
|
|
109
|
+
stdin.once('end', onStdinEnd);
|
|
110
|
+
process.once('SIGINT', onSignal);
|
|
111
|
+
process.once('SIGTERM', onSignal);
|
|
112
|
+
transport.onerror = () => stderr.write('[sap-ai-integration] MCP stdio transport failed.\n');
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
await runtime.server.connect(transport);
|
|
116
|
+
} catch {
|
|
117
|
+
stderr.write('[sap-ai-integration] MCP server startup failed.\n');
|
|
118
|
+
await shutdown();
|
|
119
|
+
process.exitCode = 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
|
|
124
|
+
await runIntegrationServer();
|
|
125
|
+
}
|