sap-ai-dev-toolkit 0.9.9922 → 0.9.9923
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-btp-infrastructure-admin.agent.md +23 -0
- package/.github/agents/sap-bw-datasphere-migration-specialist.agent.md +22 -0
- package/.github/agents/sap-datasphere-developer.agent.md +22 -0
- package/.github/agents/sap-devops-observability-specialist.agent.md +54 -0
- package/.github/agents/sap-process-automation-developer.agent.md +50 -0
- package/.github/skills/btp-platform-administration/SKILL.md +53 -0
- package/.github/skills/bw-datasphere-migration/SKILL.md +66 -0
- package/.github/skills/cloud-alm-operations/SKILL.md +135 -0
- package/.github/skills/datasphere-administration/SKILL.md +40 -0
- package/.github/skills/datasphere-development/SKILL.md +55 -0
- package/.github/skills/process-automation-development/SKILL.md +113 -0
- package/README.md +152 -8
- package/inventory.md +60 -0
- package/package.json +8 -2
- package/src/btp-cli.mjs +199 -0
- package/src/btp-server.mjs +125 -0
- package/src/btp-tools.mjs +776 -0
- package/src/cloud-alm-client.mjs +44 -0
- package/src/cloud-alm-config.mjs +92 -0
- package/src/cloud-alm-credentials.mjs +29 -0
- package/src/cloud-alm-data.mjs +129 -0
- package/src/cloud-alm-server.mjs +115 -0
- package/src/cloud-alm-tools.mjs +682 -0
- package/src/datasphere-auth.mjs +279 -0
- package/src/datasphere-cli.mjs +220 -0
- package/src/datasphere-command.mjs +236 -0
- package/src/datasphere-config.mjs +154 -0
- package/src/datasphere-credentials.mjs +37 -0
- package/src/datasphere-csn.mjs +73 -0
- package/src/datasphere-discovery.mjs +151 -0
- package/src/datasphere-server.mjs +249 -0
- package/src/datasphere-tools.mjs +809 -0
- package/src/hana-database.mjs +3 -3
- package/src/hana-inspector.mjs +2 -2
- package/src/harnesses.mjs +31 -1
- package/src/install-user-assets.mjs +1 -1
- package/src/integration-common.mjs +7 -7
- package/src/integration-config.mjs +5 -0
- package/src/integration-credentials.mjs +25 -16
- package/src/integration-http.mjs +24 -7
- package/src/launcher.mjs +56 -4
- package/src/mcp-config.mjs +37 -1
- package/src/process-automation-automation-tools.mjs +125 -0
- package/src/process-automation-client.mjs +36 -0
- package/src/process-automation-common.mjs +69 -0
- package/src/process-automation-config.mjs +142 -0
- package/src/process-automation-credentials.mjs +37 -0
- package/src/process-automation-inbox-tools.mjs +246 -0
- package/src/process-automation-server.mjs +139 -0
- package/src/process-automation-tools.mjs +727 -0
- package/src/process-automation-visibility-tools.mjs +182 -0
- package/src/setup.mjs +623 -11
- package/tools.md +138 -0
package/src/hana-database.mjs
CHANGED
|
@@ -125,7 +125,7 @@ export class HanaDatabase {
|
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
export async function
|
|
128
|
+
export async function connectHana(config, { driver, queryTimeoutMs } = {}) {
|
|
129
129
|
let selectedDriver = driver;
|
|
130
130
|
if (!selectedDriver) {
|
|
131
131
|
try {
|
|
@@ -139,7 +139,7 @@ export async function connectReadOnlyHana(config, { driver } = {}) {
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
const connection = selectedDriver.createConnection();
|
|
142
|
-
const database = new HanaDatabase(connection, config);
|
|
142
|
+
const database = new HanaDatabase(connection, config, { queryTimeoutMs });
|
|
143
143
|
try {
|
|
144
144
|
await database.connect();
|
|
145
145
|
return database;
|
|
@@ -147,4 +147,4 @@ export async function connectReadOnlyHana(config, { driver } = {}) {
|
|
|
147
147
|
await database.close();
|
|
148
148
|
throw error;
|
|
149
149
|
}
|
|
150
|
-
}
|
|
150
|
+
}
|
package/src/hana-inspector.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
4
4
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
5
5
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
6
|
import { resolveHanaReadOnlyConfig } from './hana-config.mjs';
|
|
7
|
-
import {
|
|
7
|
+
import { connectHana } from './hana-database.mjs';
|
|
8
8
|
import { registerHanaTools } from './hana-tools.mjs';
|
|
9
9
|
|
|
10
10
|
const packageMetadata = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
@@ -21,7 +21,7 @@ function publicConnectionSummary(config) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/** Create an injectable server runtime for tests and the stdio executable. */
|
|
24
|
-
export function createHanaInspector({ env = process.env, driver, databaseFactory =
|
|
24
|
+
export function createHanaInspector({ env = process.env, driver, databaseFactory = connectHana } = {}) {
|
|
25
25
|
const config = resolveHanaReadOnlyConfig(env);
|
|
26
26
|
const summary = publicConnectionSummary(config);
|
|
27
27
|
let databasePromise;
|
package/src/harnesses.mjs
CHANGED
|
@@ -92,6 +92,36 @@ User request: $ARGUMENTS
|
|
|
92
92
|
`);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
const OMP_HOST_NOTE = '**OMP host note.** OMP starts the MCP servers configured in its `mcp.json` and exposes each tool in this session\'s tool inventory as `mcp__<server>_<tool>` (for example `mcp__<server>_get_source`); tools listed only as `xd://` tool devices are called by writing their JSON arguments to that path after reading its docs. Treat that inventory as the live `tools/list` referenced below. The other bundled SAP agents are installed as OMP task agents named after their kebab-case ids (for example `abap-developer`, `sap-integration-developer`); hand work to them with the `task` tool when a handoff is required.';
|
|
96
|
+
|
|
97
|
+
export function ompAgentName(fileName) {
|
|
98
|
+
return String(fileName).replace(/\.agent\.md$/, '').replace(/\.md$/, '');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function ompAgentDocument(source, { fileName } = {}) {
|
|
102
|
+
const frontmatter = splitFrontmatter(source);
|
|
103
|
+
if (!frontmatter) throw new Error('unsupported agent frontmatter');
|
|
104
|
+
const name = requiredField(frontmatter.fields, 'name');
|
|
105
|
+
const description = requiredField(frontmatter.fields, 'description');
|
|
106
|
+
if (!name || !description || !fileName) throw new Error('unsupported agent frontmatter');
|
|
107
|
+
const body = frontmatter.body
|
|
108
|
+
.replaceAll('The Copilot host initializes MCP and supplies its live `tools/list` and schemas in the Chat tools picker.', 'OMP initializes MCP and supplies the live tools and schemas in this session\'s tool inventory.')
|
|
109
|
+
.replaceAll('from the Chat tools picker', 'from this session\'s tool inventory')
|
|
110
|
+
.replaceAll('visible in the picker', 'listed in the session\'s tool inventory')
|
|
111
|
+
.replaceAll('If the Copilot client supports subagents/delegation', 'If the OMP `task` tool is available');
|
|
112
|
+
// JSON strings are valid YAML double-quoted scalars, so descriptions with
|
|
113
|
+
// `: ` or `#` cannot break OMP's frontmatter parser.
|
|
114
|
+
return renderDocument([
|
|
115
|
+
{ key: 'name', value: ompAgentName(fileName) },
|
|
116
|
+
{ key: 'description', value: JSON.stringify(`${name}: ${description}`) }
|
|
117
|
+
], `You are the **${name}** SAP agent.
|
|
118
|
+
|
|
119
|
+
${OMP_HOST_NOTE}
|
|
120
|
+
|
|
121
|
+
${body.trim()}
|
|
122
|
+
`);
|
|
123
|
+
}
|
|
124
|
+
|
|
95
125
|
export const HARNESSES = [
|
|
96
126
|
{ id: 'github-copilot', label: 'GitHub Copilot', directory: '.copilot', supportsAgents: true, legacyManifest: true },
|
|
97
127
|
{ id: 'claude-code', label: 'Claude Code', directory: '.claude', supportsAgents: true, agentFileName: agentMarkdownName, agentDocument: claudeCodeAgentDocument },
|
|
@@ -100,7 +130,7 @@ export const HARNESSES = [
|
|
|
100
130
|
{ id: 'gemini-cli', label: 'Gemini CLI', directory: '.gemini', supportsAgents: false },
|
|
101
131
|
{ id: 'opencode', label: 'opencode', directory: 'opencode', configHome: true, supportsAgents: true, agentFileName: agentMarkdownName, agentDocument: opencodeAgentDocument },
|
|
102
132
|
{ id: 'pi-coding-agent', label: 'Pi Coding Agent', directory: '.pi/agent', supportsAgents: true, agentTargetPrefix: 'prompts', agentFileName: agentMarkdownName, agentDocument: piCodingAgentPromptDocument, root: piCodingAgentRoot },
|
|
103
|
-
{ id: 'omp', label: 'Oh My Pi (OMP)', directory: '.omp/agent', supportsAgents:
|
|
133
|
+
{ id: 'omp', label: 'Oh My Pi (OMP)', directory: '.omp/agent', supportsAgents: true, agentFileName: agentMarkdownName, agentDocument: ompAgentDocument, root: ompAgentRoot },
|
|
104
134
|
{ id: 'cline', label: 'Cline', directory: '.cline', supportsAgents: false },
|
|
105
135
|
{ id: 'roo-code', label: 'Roo Code', directory: '.roo', supportsAgents: false },
|
|
106
136
|
{ id: 'windsurf', label: 'Windsurf / Devin Desktop', directory: 'devin', supportsAgents: false, root: windsurfSkillsRoot }
|
|
@@ -83,7 +83,7 @@ async function planDestinations(harness, files) {
|
|
|
83
83
|
join('agents', fileName)
|
|
84
84
|
];
|
|
85
85
|
const aliases = [...new Set(aliasCandidates.filter(alias => alias !== target))];
|
|
86
|
-
const content = harness.agentDocument ? Buffer.from(harness.agentDocument(raw.toString('utf8')), 'utf8') : raw;
|
|
86
|
+
const content = harness.agentDocument ? Buffer.from(harness.agentDocument(raw.toString('utf8'), { fileName: sourceName }), 'utf8') : raw;
|
|
87
87
|
destinations.push({ target, content, aliases });
|
|
88
88
|
}
|
|
89
89
|
return destinations;
|
|
@@ -34,27 +34,27 @@ function jsonSafe(value, seen = new WeakSet()) {
|
|
|
34
34
|
return value;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function jsonText(value) {
|
|
37
|
+
function jsonText(value, { product = 'Integration Suite' } = {}) {
|
|
38
38
|
const text = JSON.stringify(jsonSafe(value), null, 2);
|
|
39
39
|
if (Buffer.byteLength(text, 'utf8') > INTEGRATION_MAX_OUTPUT_BYTES) {
|
|
40
|
-
throw new IntegrationError(
|
|
40
|
+
throw new IntegrationError(`${product} result is too large to return safely; narrow the request (for example file_path, $select or a smaller top).`);
|
|
41
41
|
}
|
|
42
42
|
return text;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
export function toolError(error) {
|
|
46
|
-
let message =
|
|
45
|
+
export function toolError(error, { fallback = 'Integration Suite request failed. Check service-key roles and tenant reachability.' } = {}) {
|
|
46
|
+
let message = fallback;
|
|
47
47
|
if (error?.name === 'IntegrationError' || (typeof error?.message === 'string' && INTEGRATION_CONFIG_ERROR.test(error.message))) {
|
|
48
48
|
message = error.message;
|
|
49
49
|
}
|
|
50
50
|
return { isError: true, content: [{ type: 'text', text: message }] };
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
export async function resultOf(operation) {
|
|
53
|
+
export async function resultOf(operation, options) {
|
|
54
54
|
try {
|
|
55
|
-
return { content: [{ type: 'text', text: jsonText(await operation()) }] };
|
|
55
|
+
return { content: [{ type: 'text', text: jsonText(await operation(), options) }] };
|
|
56
56
|
} catch (error) {
|
|
57
|
-
return toolError(error);
|
|
57
|
+
return toolError(error, options);
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
|
|
@@ -53,6 +53,11 @@ function finalize(prefix, source, bindingName, raw) {
|
|
|
53
53
|
});
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** Validate and freeze raw OAuth client credentials (client-credentials style products). */
|
|
57
|
+
export function finalizeClientCredentials(prefix, source, raw) {
|
|
58
|
+
return finalize(prefix, source, null, raw);
|
|
59
|
+
}
|
|
60
|
+
|
|
56
61
|
function explicitConfig(env, prefix) {
|
|
57
62
|
const keys = [`${prefix}_URL`, `${prefix}_TOKEN_URL`, `${prefix}_CLIENT_ID`, `${prefix}_CLIENT_SECRET`];
|
|
58
63
|
if (!keys.some(key => env[key] !== undefined && env[key] !== '')) return null;
|
|
@@ -4,17 +4,37 @@
|
|
|
4
4
|
import { readFileSync } from 'node:fs';
|
|
5
5
|
import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
|
-
import { dirname, join } from 'node:path';
|
|
7
|
+
import { basename, dirname, join } from 'node:path';
|
|
8
8
|
import { randomUUID } from 'node:crypto';
|
|
9
9
|
import { OPTIONAL_COMPONENT_KEYS, optionalConfigRecord } from './integration-optional-config.mjs';
|
|
10
10
|
|
|
11
11
|
const SIDES = ['cpi', 'apim', 'devhub', ...OPTIONAL_COMPONENT_KEYS];
|
|
12
|
-
|
|
13
|
-
export function
|
|
12
|
+
/** XDG/HOME path of a user-only credential file owned by this toolkit. */
|
|
13
|
+
export function userConfigFilePath(env = process.env, fileName) {
|
|
14
14
|
const configHome = typeof env.XDG_CONFIG_HOME === 'string' && env.XDG_CONFIG_HOME.trim()
|
|
15
15
|
? env.XDG_CONFIG_HOME.trim()
|
|
16
16
|
: join(env.HOME || env.USERPROFILE || homedir(), '.config');
|
|
17
|
-
return join(configHome, 'sap-ai-dev-toolkit',
|
|
17
|
+
return join(configHome, 'sap-ai-dev-toolkit', fileName);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Atomic, user-only (0600) JSON write shared by every credential store. */
|
|
21
|
+
export async function writeUserOnlyJson(path, value) {
|
|
22
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
23
|
+
await chmod(dirname(path), 0o700).catch(() => {});
|
|
24
|
+
const temporary = join(dirname(path), `.${basename(path, '.json')}.${process.pid}.${randomUUID()}.tmp`);
|
|
25
|
+
try {
|
|
26
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
27
|
+
await rename(temporary, path);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
await chmod(path, 0o600).catch(() => {});
|
|
33
|
+
return path;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function integrationCredentialsPath(env = process.env) {
|
|
37
|
+
return userConfigFilePath(env, 'integration-suite.json');
|
|
18
38
|
}
|
|
19
39
|
|
|
20
40
|
/** Stored keys/normalized optional records; malformed optional records remain isolated by the resolver. */
|
|
@@ -56,16 +76,5 @@ export async function storeIntegrationKey(side, config, env = process.env) {
|
|
|
56
76
|
...existing,
|
|
57
77
|
[key]: record
|
|
58
78
|
};
|
|
59
|
-
|
|
60
|
-
await chmod(dirname(path), 0o700).catch(() => {});
|
|
61
|
-
const temporary = join(dirname(path), `.integration-suite.${process.pid}.${randomUUID()}.tmp`);
|
|
62
|
-
try {
|
|
63
|
-
await writeFile(temporary, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
64
|
-
await rename(temporary, path);
|
|
65
|
-
} catch (error) {
|
|
66
|
-
await rm(temporary, { force: true }).catch(() => {});
|
|
67
|
-
throw error;
|
|
68
|
-
}
|
|
69
|
-
await chmod(path, 0o600).catch(() => {});
|
|
70
|
-
return path;
|
|
79
|
+
return writeUserOnlyJson(path, next);
|
|
71
80
|
}
|
package/src/integration-http.mjs
CHANGED
|
@@ -12,7 +12,11 @@ const SERVER_RETRIES = 2;
|
|
|
12
12
|
const RETRYABLE_STATUS = new Set([500, 502, 503, 504]);
|
|
13
13
|
// Only idempotent methods are retried; POST (create/deploy/import) is never re-sent.
|
|
14
14
|
const RETRYABLE_METHODS = new Set(['GET', 'PUT']);
|
|
15
|
-
|
|
15
|
+
// Readable response headers: pagination tokens and counts, and async job locations (never credentials or cookies).
|
|
16
|
+
const READABLE_RESPONSE_HEADERS = new Set(['elements-next-page-token', 'nextpage', 'location', 'x-total-count']);
|
|
17
|
+
// Transport-owned headers a caller can never set or override.
|
|
18
|
+
const RESERVED_REQUEST_HEADERS = new Set(['authorization', 'cookie', 'host', 'content-length', 'content-type', 'accept', 'accept-encoding', 'x-csrf-token', 'connection', 'transfer-encoding']);
|
|
19
|
+
const HEADER_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
16
20
|
|
|
17
21
|
export class IntegrationError extends Error {
|
|
18
22
|
constructor(message, evidence = {}) {
|
|
@@ -39,7 +43,7 @@ function discard(response) {
|
|
|
39
43
|
void response.body?.cancel().catch(() => {});
|
|
40
44
|
}
|
|
41
45
|
|
|
42
|
-
async function readCapped(response, limit, label, signal) {
|
|
46
|
+
export async function readCapped(response, limit, label, signal) {
|
|
43
47
|
const declared = Number(response.headers.get('content-length'));
|
|
44
48
|
if (Number.isFinite(declared) && declared > limit) {
|
|
45
49
|
discard(response);
|
|
@@ -340,24 +344,37 @@ function createTransportClient({
|
|
|
340
344
|
withStatus = false,
|
|
341
345
|
unwrap = true,
|
|
342
346
|
deadline,
|
|
343
|
-
responseHeaders
|
|
347
|
+
responseHeaders,
|
|
348
|
+
headers: extraHeaders
|
|
344
349
|
} = {}) {
|
|
345
350
|
if (deadline !== undefined && !Number.isFinite(deadline)) throw new IntegrationError(`${label} request deadline must be an absolute finite timestamp`);
|
|
346
351
|
if (deadline !== undefined && now() >= deadline) throw timeoutError('request', deadline);
|
|
347
352
|
if (responseHeaders !== undefined && (!Array.isArray(responseHeaders)
|
|
348
|
-
|| responseHeaders.some(name => typeof name !== 'string' || !
|
|
349
|
-
throw new IntegrationError(`${label} response headers are limited to documented pagination tokens`);
|
|
353
|
+
|| responseHeaders.some(name => typeof name !== 'string' || !READABLE_RESPONSE_HEADERS.has(name.toLowerCase())))) {
|
|
354
|
+
throw new IntegrationError(`${label} response headers are limited to documented pagination tokens and job locations`);
|
|
355
|
+
}
|
|
356
|
+
const callerHeaders = {};
|
|
357
|
+
for (const [name, value] of Object.entries(extraHeaders || {})) {
|
|
358
|
+
const normalized = name.toLowerCase();
|
|
359
|
+
if (!HEADER_NAME.test(normalized) || RESERVED_REQUEST_HEADERS.has(normalized) || normalized.startsWith('proxy-')) {
|
|
360
|
+
throw new IntegrationError(`${label} request header ${name} is not allowed`);
|
|
361
|
+
}
|
|
362
|
+
if (typeof value !== 'string' || /[^\x20-\x7e]/.test(value)) throw new IntegrationError(`${label} request header ${name} has an invalid value`);
|
|
363
|
+
callerHeaders[normalized] = value;
|
|
350
364
|
}
|
|
351
365
|
const target = new URL(`${url}${path}`);
|
|
352
366
|
for (const [key, value] of Object.entries(query || {})) {
|
|
353
|
-
if (
|
|
367
|
+
if (Array.isArray(value)) {
|
|
368
|
+
// Repeated parameters (`status=A&status=B`) carry OR semantics in SAP REST APIs.
|
|
369
|
+
for (const item of value) if (item !== undefined && item !== null) target.searchParams.append(key, String(item));
|
|
370
|
+
} else if (value !== undefined && value !== null) target.searchParams.set(key, String(value));
|
|
354
371
|
}
|
|
355
372
|
// SAP management endpoints require literal OData '$' option names and '%20' spaces.
|
|
356
373
|
// Decode only a leading '$' in parameter names; values stay encoded (including '+' and '&').
|
|
357
374
|
target.search = target.search.replaceAll('+', '%20').replace(/([?&])%24(?=[A-Za-z_])/gi, (_, separator) => `${separator}$`);
|
|
358
375
|
if (target.origin !== origin || target.username || target.password || target.hash) throw new IntegrationError(`${label} request escaped the configured tenant`);
|
|
359
376
|
|
|
360
|
-
const baseHeaders = { accept };
|
|
377
|
+
const baseHeaders = { ...callerHeaders, accept };
|
|
361
378
|
let payload;
|
|
362
379
|
if (json !== undefined) {
|
|
363
380
|
baseHeaders['content-type'] = 'application/json';
|
package/src/launcher.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { adtBaseUrl, adtPort, detectAdtUrl, discoverSapGuiSystems, hasExplicitPo
|
|
|
10
10
|
import { binaryTarget, cacheDirectory, downloadBinary, findBinary } from './binary.mjs';
|
|
11
11
|
import { MCPProxy } from './mcp-proxy.mjs';
|
|
12
12
|
import { configuredVersionHandoff, installMcpConfig, npxPackageLauncher, repairManagedMcpConfig } from './mcp-config.mjs';
|
|
13
|
-
import { runIntegrationSuiteSetup, runSetup } from './setup.mjs';
|
|
13
|
+
import { runBtpSetup, runCloudAlmSetup, runDatasphereSetup, runIntegrationSuiteSetup, runProcessAutomationSetup, runSetup } from './setup.mjs';
|
|
14
14
|
import { runHarnessSetup } from './harness-setup.mjs';
|
|
15
15
|
import { resolveConfiguredCloudFoundryDestination } from './cf-destination.mjs';
|
|
16
16
|
import { createTlsServerNameAdtProxy } from './tls-adt-proxy.mjs';
|
|
@@ -38,7 +38,11 @@ function usage() {
|
|
|
38
38
|
' --setup Configure generated BAS MCP servers',
|
|
39
39
|
' --tools Also offer full-stack SAP companion MCP servers (use with --setup)',
|
|
40
40
|
' --setup-integration Only set up the Integration Suite MCP server: paste CPI/APIM service keys and verify them online',
|
|
41
|
-
' --
|
|
41
|
+
' --setup-datasphere Only set up the SAP Datasphere MCP server: sign in with a Datasphere OAuth client and verify it online',
|
|
42
|
+
' --setup-process-automation Only set up the SAP Build Process Automation MCP server: paste a service key, choose technical or user sign-in, verify online',
|
|
43
|
+
' --setup-btp Only set up the SAP BTP platform administration MCP server: locate the btp CLI and check your btp login --sso session',
|
|
44
|
+
' --setup-cloud-alm Only set up the SAP Cloud ALM MCP server: paste the Cloud ALM API service key, verify online',
|
|
45
|
+
' --datasphere <command> Run the bundled SAP Datasphere CLI with a tenant URL and browser temporary code',
|
|
42
46
|
' --list-destinations List discovered BAS destinations',
|
|
43
47
|
' --list-destinations --json Print redacted JSON status',
|
|
44
48
|
' --check Probe destination availability',
|
|
@@ -370,7 +374,16 @@ async function configuredLocalSapGuiDestination(env) {
|
|
|
370
374
|
}
|
|
371
375
|
|
|
372
376
|
async function main() {
|
|
377
|
+
if (args()[0] === '--datasphere') {
|
|
378
|
+
const { runDatasphereCommand } = await import('./datasphere-command.mjs');
|
|
379
|
+
process.exitCode = await runDatasphereCommand(args().slice(1), { env: runtimeEnv });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
373
382
|
const setupIntegration = hasFlag('--setup-integration');
|
|
383
|
+
const setupDatasphere = hasFlag('--setup-datasphere');
|
|
384
|
+
const setupProcessAutomation = hasFlag('--setup-process-automation');
|
|
385
|
+
const setupBtp = hasFlag('--setup-btp');
|
|
386
|
+
const setupCloudAlm = hasFlag('--setup-cloud-alm');
|
|
374
387
|
const setup = hasFlag('--setup');
|
|
375
388
|
const check = hasFlag('--check');
|
|
376
389
|
const list = hasFlag('--list-destinations');
|
|
@@ -380,8 +393,7 @@ async function main() {
|
|
|
380
393
|
process.stdout.write(usage());
|
|
381
394
|
return;
|
|
382
395
|
}
|
|
383
|
-
if (!setup && !setupIntegration && !check && !list && !doctor && !demo && await handOffToConfiguredVersion()) return;
|
|
384
|
-
if (doctor && demo) throw new Error('Use either --doctor or --demo, not both.');
|
|
396
|
+
if (!setup && !setupIntegration && !setupDatasphere && !setupProcessAutomation && !setupBtp && !setupCloudAlm && !check && !list && !doctor && !demo && await handOffToConfiguredVersion()) return;
|
|
385
397
|
if (demo) {
|
|
386
398
|
await runOfflineDemo();
|
|
387
399
|
return;
|
|
@@ -419,6 +431,46 @@ async function main() {
|
|
|
419
431
|
if (!result?.skipped) await runHarnessSetup(result?.servers, { env: runtimeEnv, root });
|
|
420
432
|
return;
|
|
421
433
|
}
|
|
434
|
+
if (setupDatasphere) {
|
|
435
|
+
const options = {};
|
|
436
|
+
if (hasFlag('--npx')) {
|
|
437
|
+
const launcher = npxPackageLauncher(pkg.version);
|
|
438
|
+
options.install = (selected, installOptions) => installMcpConfig(selected, { ...installOptions, ...launcher });
|
|
439
|
+
}
|
|
440
|
+
const result = await runDatasphereSetup(options);
|
|
441
|
+
if (!result?.skipped) await runHarnessSetup(result?.servers, { env: runtimeEnv, root });
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (setupProcessAutomation) {
|
|
445
|
+
const options = {};
|
|
446
|
+
if (hasFlag('--npx')) {
|
|
447
|
+
const launcher = npxPackageLauncher(pkg.version);
|
|
448
|
+
options.install = (selected, installOptions) => installMcpConfig(selected, { ...installOptions, ...launcher });
|
|
449
|
+
}
|
|
450
|
+
const result = await runProcessAutomationSetup(options);
|
|
451
|
+
if (!result?.skipped) await runHarnessSetup(result?.servers, { env: runtimeEnv, root });
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (setupBtp) {
|
|
455
|
+
const options = {};
|
|
456
|
+
if (hasFlag('--npx')) {
|
|
457
|
+
const launcher = npxPackageLauncher(pkg.version);
|
|
458
|
+
options.install = (selected, installOptions) => installMcpConfig(selected, { ...installOptions, ...launcher });
|
|
459
|
+
}
|
|
460
|
+
const result = await runBtpSetup(options);
|
|
461
|
+
if (!result?.skipped) await runHarnessSetup(result?.servers, { env: runtimeEnv, root });
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (setupCloudAlm) {
|
|
465
|
+
const options = {};
|
|
466
|
+
if (hasFlag('--npx')) {
|
|
467
|
+
const launcher = npxPackageLauncher(pkg.version);
|
|
468
|
+
options.install = (selected, installOptions) => installMcpConfig(selected, { ...installOptions, ...launcher });
|
|
469
|
+
}
|
|
470
|
+
const result = await runCloudAlmSetup(options);
|
|
471
|
+
if (!result?.skipped) await runHarnessSetup(result?.servers, { env: runtimeEnv, root });
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
422
474
|
if (setup) {
|
|
423
475
|
const setupOptions = {
|
|
424
476
|
includeSapDevelopmentToolsPrompt: hasFlag('--tools') || hasFlag('--companion-tools'),
|
package/src/mcp-config.mjs
CHANGED
|
@@ -76,6 +76,42 @@ export const SAP_DEVELOPMENT_MCP_SERVERS = Object.freeze([
|
|
|
76
76
|
bin: 'sap-ai-integration',
|
|
77
77
|
ignoreScripts: true,
|
|
78
78
|
priority: 'optional'
|
|
79
|
+
}),
|
|
80
|
+
Object.freeze({
|
|
81
|
+
id: 'datasphere',
|
|
82
|
+
name: 'SAP Datasphere',
|
|
83
|
+
description: 'Full SAP Datasphere suite: spaces, users, roles, database users, connections, modeling objects and packages, task chains, replication flows, Data Marketplace, consumption data and Open SQL, using a Datasphere OAuth client from the host environment or saved with sap-ai-dev --setup-datasphere.',
|
|
84
|
+
packageName: 'sap-ai-dev-toolkit',
|
|
85
|
+
bin: 'sap-ai-datasphere',
|
|
86
|
+
ignoreScripts: true,
|
|
87
|
+
priority: 'optional'
|
|
88
|
+
}),
|
|
89
|
+
Object.freeze({
|
|
90
|
+
id: 'process-automation',
|
|
91
|
+
name: 'SAP Build Process Automation',
|
|
92
|
+
description: 'Inspect process definitions, running, failed and stuck workflow instances, user-task queues and decisions; start processes, send business events, and administer instances and tasks, using a Process Automation service key from the host environment or saved with sap-ai-dev --setup-process-automation.',
|
|
93
|
+
packageName: 'sap-ai-dev-toolkit',
|
|
94
|
+
bin: 'sap-ai-process-automation',
|
|
95
|
+
ignoreScripts: true,
|
|
96
|
+
priority: 'optional'
|
|
97
|
+
}),
|
|
98
|
+
Object.freeze({
|
|
99
|
+
id: 'sap-btp',
|
|
100
|
+
name: 'SAP BTP platform administration',
|
|
101
|
+
description: 'Inspect the global account hierarchy, entitlements and quotas, Service Manager instances and service keys, destinations, identity and destination trust, and Cloud Connectors, and create service instances and keys, through your own btp CLI login (btp login --sso) set up with sap-ai-dev --setup-btp.',
|
|
102
|
+
packageName: 'sap-ai-dev-toolkit',
|
|
103
|
+
bin: 'sap-ai-btp',
|
|
104
|
+
ignoreScripts: true,
|
|
105
|
+
priority: 'optional'
|
|
106
|
+
}),
|
|
107
|
+
Object.freeze({
|
|
108
|
+
id: 'cloud-alm',
|
|
109
|
+
name: 'SAP Cloud ALM',
|
|
110
|
+
description: 'Observe and triage SAP landscapes with SAP Cloud ALM: health, exception and alert hotspots per DEV/TEST/PROD tier, integration and exception monitoring records, monitoring events with CPI message log and SLG1 handoffs, analytics queries, feature and transport deployment status, and confirmation-gated alert closing, using a Cloud ALM API service key from the host environment or saved with sap-ai-dev --setup-cloud-alm.',
|
|
111
|
+
packageName: 'sap-ai-dev-toolkit',
|
|
112
|
+
bin: 'sap-ai-cloud-alm',
|
|
113
|
+
ignoreScripts: true,
|
|
114
|
+
priority: 'optional'
|
|
79
115
|
})
|
|
80
116
|
]);
|
|
81
117
|
|
|
@@ -608,7 +644,7 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
|
|
|
608
644
|
// Values are full npx package specs; a bare version would become --package=<version>.
|
|
609
645
|
const selfSpec = selfVersion ? `sap-ai-dev-toolkit@${selfVersion}` : '';
|
|
610
646
|
const companionVersions = selfSpec
|
|
611
|
-
? { 'sap-ai-dev-toolkit': selfSpec, 'hana-cloud-inspector': selfSpec, 'integration-suite': selfSpec }
|
|
647
|
+
? { 'sap-ai-dev-toolkit': selfSpec, 'hana-cloud-inspector': selfSpec, 'integration-suite': selfSpec, 'datasphere': selfSpec, 'process-automation': selfSpec, 'sap-btp': selfSpec, 'cloud-alm': selfSpec }
|
|
612
648
|
: {};
|
|
613
649
|
const generated = {
|
|
614
650
|
...buildMcpEntries(destinations, env),
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// SAP Build Process Automation Unified API triggers and automation (iRPA) execution/monitoring tools.
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { CREATE_ANNOTATIONS, READ_ONLY_ANNOTATIONS, confirmation, requireConfirmation } from './integration-common.mjs';
|
|
4
|
+
import {
|
|
5
|
+
IRPA_MONITORING, IRPA_RUNTIME, UNIFIED, apiKeyHeaders, enc, environmentId, environmentQuery, guid, identifier,
|
|
6
|
+
outputFile, payload, timestamp, writeExportFile
|
|
7
|
+
} from './process-automation-common.mjs';
|
|
8
|
+
|
|
9
|
+
export const AUTOMATION_TOOL_NAMES = Object.freeze([
|
|
10
|
+
'bpa_list_api_triggers',
|
|
11
|
+
'bpa_get_api_trigger',
|
|
12
|
+
'bpa_execute_api_trigger',
|
|
13
|
+
'bpa_list_automation_triggers',
|
|
14
|
+
'bpa_get_automation_trigger',
|
|
15
|
+
'bpa_list_automation_jobs',
|
|
16
|
+
'bpa_start_automation',
|
|
17
|
+
'bpa_export_automation_data'
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const EXPORT_PATHS = { bam: '/bam/export', archived_jobs: '/jobs/archived/export' };
|
|
21
|
+
|
|
22
|
+
export function registerAutomationTools(register, { rest, access }) {
|
|
23
|
+
const triggerUid = identifier('trigger uid (UUID or projectId.triggerId)');
|
|
24
|
+
const unified = (method, path, environment, options = {}) => rest.request(method, `${UNIFIED}${path}`, {
|
|
25
|
+
...options,
|
|
26
|
+
query: { ...environmentQuery(access, environment), ...(options.query ?? {}) },
|
|
27
|
+
headers: apiKeyHeaders(access, 'api-key')
|
|
28
|
+
});
|
|
29
|
+
const irpa = (method, path, options = {}) => rest.request(method, `${IRPA_RUNTIME}${path}`, {
|
|
30
|
+
...options,
|
|
31
|
+
headers: apiKeyHeaders(access, 'irpa-api-key', { required: true })
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
register('bpa_list_api_triggers', 'List API triggers',
|
|
35
|
+
'List process API triggers of an environment via the Unified API (api-key scope trigger_read when using a technical user).',
|
|
36
|
+
{
|
|
37
|
+
environment_id: environmentId,
|
|
38
|
+
filter: z.string().max(1000).optional().describe('OData $filter expression.'),
|
|
39
|
+
expand: z.string().max(200).optional().describe('OData $expand expression.')
|
|
40
|
+
},
|
|
41
|
+
READ_ONLY_ANNOTATIONS,
|
|
42
|
+
({ environment_id, filter, expand }) => unified('GET', '/triggers/api', environment_id, {
|
|
43
|
+
query: { ...(filter ? { $filter: filter } : {}), ...(expand ? { $expand: expand } : {}) }
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
register('bpa_get_api_trigger', 'Get API trigger',
|
|
47
|
+
'Get one process API trigger by UUID or projectId.triggerId (environment_id required for the latter; api-key scope trigger_read).',
|
|
48
|
+
{ trigger_uid: triggerUid, environment_id: environmentId },
|
|
49
|
+
READ_ONLY_ANNOTATIONS,
|
|
50
|
+
({ trigger_uid, environment_id }) => unified('GET', `/triggers/api/${enc(trigger_uid)}`, environment_id));
|
|
51
|
+
|
|
52
|
+
register('bpa_execute_api_trigger', 'Execute API trigger',
|
|
53
|
+
'Start a process through its API trigger, or resume a "Wait for an API Call" step by passing execution_id (the process instance id); api-key scope trigger_execute.',
|
|
54
|
+
{
|
|
55
|
+
trigger_uid: triggerUid,
|
|
56
|
+
inputs: payload.default({}),
|
|
57
|
+
business_key: z.string().max(255).optional(),
|
|
58
|
+
execution_id: guid.optional().describe('Process instance id waiting in a "Wait for an API Call" step.'),
|
|
59
|
+
environment_id: environmentId,
|
|
60
|
+
confirmation
|
|
61
|
+
},
|
|
62
|
+
CREATE_ANNOTATIONS,
|
|
63
|
+
async ({ trigger_uid, inputs, business_key, execution_id, environment_id, confirmation: given }) => {
|
|
64
|
+
requireConfirmation(given, `EXECUTE BPA TRIGGER ${trigger_uid}`);
|
|
65
|
+
const { status, data } = await unified('POST', `/triggers/api/${enc(trigger_uid)}`, environment_id, {
|
|
66
|
+
json: { inputs, ...(business_key ? { businessKey: business_key } : {}), ...(execution_id ? { executionId: execution_id } : {}) },
|
|
67
|
+
expect: [200, 201, 202, 204],
|
|
68
|
+
withStatus: true
|
|
69
|
+
});
|
|
70
|
+
return { status, data: data ?? null };
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
register('bpa_list_automation_triggers', 'List automation API triggers',
|
|
74
|
+
'List automation (iRPA) API triggers; requires an environment API key with scope TRIGGER_READ.',
|
|
75
|
+
{},
|
|
76
|
+
READ_ONLY_ANNOTATIONS,
|
|
77
|
+
() => irpa('GET', '/apiTriggers'));
|
|
78
|
+
|
|
79
|
+
register('bpa_get_automation_trigger', 'Get automation API trigger',
|
|
80
|
+
'Get one automation API trigger; requires an environment API key with scope TRIGGER_READ.',
|
|
81
|
+
{ trigger_uid: triggerUid },
|
|
82
|
+
READ_ONLY_ANNOTATIONS,
|
|
83
|
+
({ trigger_uid }) => irpa('GET', `/apiTriggers/${enc(trigger_uid)}`));
|
|
84
|
+
|
|
85
|
+
register('bpa_list_automation_jobs', 'List automation jobs',
|
|
86
|
+
'List jobs (runs) of an automation API trigger, or get one job by job_uid; requires an environment API key with scope TRIGGER_READ.',
|
|
87
|
+
{ trigger_uid: triggerUid, job_uid: identifier('job uid').optional() },
|
|
88
|
+
READ_ONLY_ANNOTATIONS,
|
|
89
|
+
({ trigger_uid, job_uid }) => irpa('GET', job_uid
|
|
90
|
+
? `/apiTriggers/${enc(trigger_uid)}/runs/${enc(job_uid)}`
|
|
91
|
+
: `/apiTriggers/${enc(trigger_uid)}/runs`));
|
|
92
|
+
|
|
93
|
+
register('bpa_start_automation', 'Start automation',
|
|
94
|
+
'Start an automation job through its API trigger; requires an environment API key with scope TRIGGER_EXECUTE.',
|
|
95
|
+
{ trigger_uid: triggerUid, input: payload.default({}), invocation_context: payload.optional(), confirmation },
|
|
96
|
+
CREATE_ANNOTATIONS,
|
|
97
|
+
async ({ trigger_uid, input, invocation_context, confirmation: given }) => {
|
|
98
|
+
requireConfirmation(given, `START BPA AUTOMATION ${trigger_uid}`);
|
|
99
|
+
return irpa('POST', `/apiTriggers/${enc(trigger_uid)}/runs`, {
|
|
100
|
+
json: { input, ...(invocation_context ? { invocationContext: invocation_context } : {}) },
|
|
101
|
+
expect: [201]
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
register('bpa_export_automation_data', 'Export automation monitoring data',
|
|
106
|
+
'Export business activity monitoring data (bam, scope BAM_DOWNLOAD) or archived automation jobs (archived_jobs) as CSV; requires an environment API key.',
|
|
107
|
+
{
|
|
108
|
+
kind: z.enum(['bam', 'archived_jobs']),
|
|
109
|
+
from: timestamp.optional(),
|
|
110
|
+
to: timestamp.optional(),
|
|
111
|
+
region: z.string().max(64).optional().describe('Language code controlling CSV delimiters.'),
|
|
112
|
+
output_file: outputFile.optional()
|
|
113
|
+
},
|
|
114
|
+
READ_ONLY_ANNOTATIONS,
|
|
115
|
+
async ({ kind, from, to, region, output_file }) => {
|
|
116
|
+
const headers = apiKeyHeaders(access, 'irpa-api-key', { required: true });
|
|
117
|
+
const csv = await rest.request('GET', `${IRPA_MONITORING}${EXPORT_PATHS[kind]}`, {
|
|
118
|
+
query: Object.fromEntries(Object.entries({ from, to, region }).filter(([, value]) => value !== undefined)),
|
|
119
|
+
headers,
|
|
120
|
+
accept: 'text/csv, */*',
|
|
121
|
+
responseType: 'text'
|
|
122
|
+
});
|
|
123
|
+
return output_file ? writeExportFile(output_file, csv) : { csv };
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// SAP Build Process Automation REST client, shared by the MCP server and the
|
|
2
|
+
// setup verification. Technical mode: XSUAA client credentials with CSRF and SAP
|
|
3
|
+
// error details. Interactive mode: the signed-in user's bearer token through a
|
|
4
|
+
// static-authorization client (error bodies withheld; messages keep HTTP status).
|
|
5
|
+
import { createDatasphereTokenProvider } from './datasphere-auth.mjs';
|
|
6
|
+
import { createServiceClient, createStaticServiceClient } from './integration-http.mjs';
|
|
7
|
+
import { PROCESS_AUTOMATION_OAUTH } from './process-automation-config.mjs';
|
|
8
|
+
|
|
9
|
+
export function createProcessAutomationRestClient(config, { env = process.env, fetchImpl, accessToken, persistRefreshToken } = {}) {
|
|
10
|
+
if (config.auth === 'technical') {
|
|
11
|
+
return createServiceClient({
|
|
12
|
+
label: 'PROCESS_AUTOMATION',
|
|
13
|
+
url: config.url,
|
|
14
|
+
tokenUrl: config.tokenUrl,
|
|
15
|
+
clientId: config.clientId,
|
|
16
|
+
clientSecret: config.clientSecret,
|
|
17
|
+
csrfPath: '/public/workflow/rest/v1/workflow-definitions',
|
|
18
|
+
env,
|
|
19
|
+
...(fetchImpl ? { fetchImpl } : {})
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
const provider = createDatasphereTokenProvider({
|
|
23
|
+
config,
|
|
24
|
+
env,
|
|
25
|
+
profile: PROCESS_AUTOMATION_OAUTH,
|
|
26
|
+
...(persistRefreshToken ? { persistRefreshToken } : {}),
|
|
27
|
+
...(fetchImpl ? { fetchImpl } : {})
|
|
28
|
+
});
|
|
29
|
+
return createStaticServiceClient({
|
|
30
|
+
label: 'PROCESS_AUTOMATION',
|
|
31
|
+
url: config.url,
|
|
32
|
+
authorization: async () => `Bearer ${accessToken ?? (await provider.token()).accessToken}`,
|
|
33
|
+
env,
|
|
34
|
+
...(fetchImpl ? { fetchImpl } : {})
|
|
35
|
+
});
|
|
36
|
+
}
|