sap-ai-dev-toolkit 0.3.3 → 0.3.7
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/abap-developer.agent.md +3 -1
- package/.github/agents/abap-runtime-debugger.agent.md +3 -1
- package/.github/agents/hana-cloud-hdi-specialist.agent.md +22 -0
- package/.github/agents/rap-service-developer.agent.md +3 -1
- package/.github/agents/sap-solution-architect.agent.md +6 -3
- package/.github/skills/hana-cloud-inspection/SKILL.md +35 -0
- package/.github/skills/hana-cloud-native-development/SKILL.md +26 -0
- package/.github/skills/hana-cloud-validation/SKILL.md +26 -0
- package/README.md +50 -22
- package/package.json +9 -4
- package/scripts/postinstall.mjs +3 -3
- package/src/bas-destination-relay.mjs +24 -13
- package/src/credential-overrides.mjs +75 -0
- package/src/credentials-store.mjs +14 -5
- package/src/hana-config.mjs +170 -0
- package/src/hana-database.mjs +150 -0
- package/src/hana-inspector.mjs +114 -0
- package/src/hana-tools.mjs +277 -0
- package/src/launcher.mjs +10 -32
- package/src/mcp-config.mjs +10 -1
- package/src/mcp-proxy.mjs +61 -0
- package/src/setup.mjs +87 -61
- package/test/bas-destination-relay.test.mjs +50 -0
- package/test/cf-runtime.test.mjs +27 -5
- package/test/copilot-content.test.mjs +63 -0
- package/test/credentials-store.test.mjs +31 -0
- package/test/hana-config.test.mjs +168 -0
- package/test/hana-inspector-stdio.test.mjs +44 -0
- package/test/hana-tools.test.mjs +206 -0
- package/test/launcher.test.mjs +52 -0
- package/test/live-s4h.test.mjs +202 -0
- package/test/mcp-config-cf.test.mjs +40 -0
- package/test/mcp-proxy.test.mjs +17 -31
- package/test/setup-cf.test.mjs +131 -4
- package/test/setup.test.mjs +10 -6
- package/tools.md +58 -50
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { once } from 'node:events';
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const launcher = fileURLToPath(new URL('../src/launcher.mjs', import.meta.url));
|
|
11
|
+
const LIVE = /^(1|true|yes)$/iu.test(process.env.SAP_AI_DEV_LIVE_S4H || '');
|
|
12
|
+
const DESTINATION = process.env.SAP_AI_DEV_LIVE_DESTINATION || 'S4H';
|
|
13
|
+
const SLUG = process.env.SAP_AI_DEV_LIVE_DESTINATION_SLUG || DESTINATION.toLowerCase().replace(/[^a-z0-9_-]+/gu, '-');
|
|
14
|
+
const REQUEST_TIMEOUT_MS = Number(process.env.SAP_AI_DEV_LIVE_TIMEOUT_MS || 45_000);
|
|
15
|
+
|
|
16
|
+
async function readConfiguredServer() {
|
|
17
|
+
if (/^(0|false|no)$/iu.test(process.env.SAP_AI_DEV_LIVE_USE_MCP_CONFIG || '')) return null;
|
|
18
|
+
const candidates = [
|
|
19
|
+
process.env.SAP_AI_DEV_MCP_CONFIG,
|
|
20
|
+
process.env.BAS_VSP_MCP_CONFIG,
|
|
21
|
+
join(homedir(), '.vscode', 'data', 'User', 'mcp.json'),
|
|
22
|
+
join(homedir(), '.vscode-server', 'data', 'User', 'mcp.json'),
|
|
23
|
+
join(homedir(), '.code-server', 'data', 'User', 'mcp.json')
|
|
24
|
+
].filter(Boolean);
|
|
25
|
+
for (const path of candidates) {
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
29
|
+
} catch {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const server = parsed?.servers?.[DESTINATION];
|
|
33
|
+
if (server?.command) return { path, server };
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class StdioMcpClient {
|
|
39
|
+
constructor(command, args, env) {
|
|
40
|
+
this.child = spawn(command, args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
41
|
+
this.nextId = 1;
|
|
42
|
+
this.pending = new Map();
|
|
43
|
+
this.stdout = '';
|
|
44
|
+
this.stderr = '';
|
|
45
|
+
this.notifications = [];
|
|
46
|
+
this.child.stdout.setEncoding('utf8');
|
|
47
|
+
this.child.stderr.setEncoding('utf8');
|
|
48
|
+
this.child.stdout.on('data', chunk => this.#onStdout(chunk));
|
|
49
|
+
this.child.stderr.on('data', chunk => { this.stderr += chunk; });
|
|
50
|
+
this.child.on('exit', (code, signal) => {
|
|
51
|
+
const error = new Error(`MCP process exited (${code ?? signal})\n${this.stderr.slice(-4000)}`);
|
|
52
|
+
for (const pending of this.pending.values()) pending.reject(error);
|
|
53
|
+
this.pending.clear();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#onStdout(chunk) {
|
|
58
|
+
this.stdout += chunk;
|
|
59
|
+
let newline;
|
|
60
|
+
while ((newline = this.stdout.indexOf('\n')) >= 0) {
|
|
61
|
+
const line = this.stdout.slice(0, newline).trim();
|
|
62
|
+
this.stdout = this.stdout.slice(newline + 1);
|
|
63
|
+
if (!line) continue;
|
|
64
|
+
let message;
|
|
65
|
+
try {
|
|
66
|
+
message = JSON.parse(line);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
for (const pending of this.pending.values()) pending.reject(new Error(`Invalid JSON-RPC line: ${line}`));
|
|
69
|
+
this.pending.clear();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (message.id === undefined || message.id === null) {
|
|
73
|
+
this.notifications.push(message);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const pending = this.pending.get(message.id);
|
|
77
|
+
if (!pending) continue;
|
|
78
|
+
this.pending.delete(message.id);
|
|
79
|
+
clearTimeout(pending.timeout);
|
|
80
|
+
if (message.error) pending.reject(Object.assign(new Error(message.error.message), { response: message }));
|
|
81
|
+
else pending.resolve(message.result);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
request(method, params) {
|
|
86
|
+
const id = this.nextId++;
|
|
87
|
+
const payload = { jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) };
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
const timeout = setTimeout(() => {
|
|
90
|
+
this.pending.delete(id);
|
|
91
|
+
reject(new Error(`Timed out waiting for ${method}\n${this.stderr.slice(-4000)}`));
|
|
92
|
+
}, REQUEST_TIMEOUT_MS);
|
|
93
|
+
this.pending.set(id, { resolve, reject, timeout });
|
|
94
|
+
this.child.stdin.write(`${JSON.stringify(payload)}\n`);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
notify(method, params) {
|
|
99
|
+
this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, ...(params === undefined ? {} : { params }) })}\n`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async close() {
|
|
103
|
+
this.child.stdin.end();
|
|
104
|
+
this.child.kill('SIGTERM');
|
|
105
|
+
await Promise.race([
|
|
106
|
+
once(this.child, 'exit').catch(() => {}),
|
|
107
|
+
new Promise(resolve => setTimeout(resolve, 1500))
|
|
108
|
+
]);
|
|
109
|
+
if (!this.child.killed) this.child.kill('SIGKILL');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function textPayload(result) {
|
|
114
|
+
return (result?.content || []).filter(item => item.type === 'text').map(item => item.text).join('\n');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function withLiveClient(t) {
|
|
118
|
+
const configured = await readConfiguredServer();
|
|
119
|
+
const server = configured?.server;
|
|
120
|
+
const command = process.env.SAP_AI_DEV_LIVE_COMMAND || server?.command || process.execPath;
|
|
121
|
+
const args = process.env.SAP_AI_DEV_LIVE_COMMAND
|
|
122
|
+
? (process.env.SAP_AI_DEV_LIVE_ARGS ? JSON.parse(process.env.SAP_AI_DEV_LIVE_ARGS) : [])
|
|
123
|
+
: (server?.command ? (server.args || []) : [launcher]);
|
|
124
|
+
const client = new StdioMcpClient(command, args, {
|
|
125
|
+
...process.env,
|
|
126
|
+
...(server?.env || {}),
|
|
127
|
+
SAP_AI_DEV_TOOLKIT_DESTINATION: server?.env?.SAP_AI_DEV_TOOLKIT_DESTINATION || DESTINATION,
|
|
128
|
+
SAP_AI_DEV_TOOLKIT_MODE: process.env.SAP_AI_DEV_TOOLKIT_MODE || server?.env?.SAP_AI_DEV_TOOLKIT_MODE || 'expert',
|
|
129
|
+
SAP_ALLOW_TRANSPORTABLE_EDITS: process.env.SAP_ALLOW_TRANSPORTABLE_EDITS || server?.env?.SAP_ALLOW_TRANSPORTABLE_EDITS || 'true'
|
|
130
|
+
});
|
|
131
|
+
t.after(() => client.close());
|
|
132
|
+
const initialized = await client.request('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'sap-ai-dev-live-test', version: '1.0.0' } });
|
|
133
|
+
client.notify('notifications/initialized');
|
|
134
|
+
assert.equal(initialized.serverInfo.name, DESTINATION);
|
|
135
|
+
return client;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
test('live S4H MCP exposes a curated ABAP lifecycle surface under 60 tools', { skip: LIVE ? false : 'set SAP_AI_DEV_LIVE_S4H=1 to run against a live BAS/S4H destination' }, async t => {
|
|
139
|
+
const client = await withLiveClient(t);
|
|
140
|
+
const listed = await client.request('tools/list', {});
|
|
141
|
+
const tools = listed.tools || [];
|
|
142
|
+
const names = new Set(tools.map(tool => tool.name));
|
|
143
|
+
const prefix = `${SLUG}__`;
|
|
144
|
+
|
|
145
|
+
assert.ok(tools.length > 25, `expected a useful ABAP lifecycle surface, got ${tools.length}`);
|
|
146
|
+
assert.ok(tools.length < 60, `curated destination tool count must stay below 60, got ${tools.length}`);
|
|
147
|
+
assert.ok(tools.every(tool => tool.name.startsWith(prefix)), 'every tool is destination-prefixed for the selected S4H server');
|
|
148
|
+
|
|
149
|
+
for (const required of [
|
|
150
|
+
'LintABAP', 'GetSystemInfo', 'GetConnectionInfo', 'GetFeatures',
|
|
151
|
+
'SearchObject', 'GrepObjects', 'GrepPackages', 'GetSource', 'WriteSource', 'EditSource', 'CompareSource',
|
|
152
|
+
'SyntaxCheck', 'PrettyPrint', 'Activate', 'RunUnitTests', 'RunATCCheck', 'GetInactiveObjects',
|
|
153
|
+
'GetClass', 'GetProgram', 'GetPackage', 'GetTable', 'GetTableContents', 'RunQuery',
|
|
154
|
+
'GetTransport', 'GetTransportInfo', 'ListTransports', 'CreateTransport',
|
|
155
|
+
'GetApplicationLog', 'PrepareABAPChangeSet', 'ApplyABAPChangeSet', 'CheckTransportReadiness', 'PlanABAPCloudMigration'
|
|
156
|
+
]) {
|
|
157
|
+
assert.ok(names.has(`${prefix}${required}`), `expected curated lifecycle tool ${required}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const hidden of [
|
|
161
|
+
'SAP', 'ReleaseTransport', 'DeleteTransport', 'DeleteObject', 'ExecuteABAP', 'CallRFC',
|
|
162
|
+
'ListSQLTraces', 'AnalyzeABAPCode', 'CreateObject', 'CreateClassWithTests', 'WriteClass', 'UpdateSource'
|
|
163
|
+
]) {
|
|
164
|
+
assert.equal(names.has(`${prefix}${hidden}`), false, `broad/destructive tool ${hidden} must remain hidden`);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('live S4H MCP handles safe local and read-only SAP calls', { skip: LIVE ? false : 'set SAP_AI_DEV_LIVE_S4H=1 to run against a live BAS/S4H destination' }, async t => {
|
|
169
|
+
const client = await withLiveClient(t);
|
|
170
|
+
const prefix = `${SLUG}__`;
|
|
171
|
+
await client.request('tools/list', {});
|
|
172
|
+
|
|
173
|
+
const lint = await client.request('tools/call', {
|
|
174
|
+
name: `${prefix}LintABAP`,
|
|
175
|
+
arguments: { files: [{ filename: 'zlive_probe.prog.abap', source: "REPORT zlive_probe.\nWRITE / 'ok'.\n" }] }
|
|
176
|
+
});
|
|
177
|
+
const lintJson = JSON.parse(textPayload(lint));
|
|
178
|
+
assert.equal(lintJson.filesChecked, 1);
|
|
179
|
+
|
|
180
|
+
for (const [tool, args] of [
|
|
181
|
+
['GetSystemInfo', {}],
|
|
182
|
+
['GetConnectionInfo', {}],
|
|
183
|
+
['GetFeatures', {}],
|
|
184
|
+
['GetInstalledComponents', {}]
|
|
185
|
+
]) {
|
|
186
|
+
const result = await client.request('tools/call', { name: `${prefix}${tool}`, arguments: args });
|
|
187
|
+
assert.notEqual(result.isError, true, `${tool} returned an MCP tool error: ${textPayload(result)}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const query = await client.request('tools/call', {
|
|
191
|
+
name: `${prefix}RunQuery`,
|
|
192
|
+
arguments: { sql_query: 'SELECT * FROM T000', max_rows: 1 }
|
|
193
|
+
});
|
|
194
|
+
assert.notEqual(query.isError, true, `RunQuery returned an MCP tool error: ${textPayload(query)}`);
|
|
195
|
+
assert.match(textPayload(query), /T000|MANDT|client|rows|\[/iu);
|
|
196
|
+
|
|
197
|
+
const appLog = await client.request('tools/call', {
|
|
198
|
+
name: `${prefix}GetApplicationLog`,
|
|
199
|
+
arguments: { max_results: 1 }
|
|
200
|
+
});
|
|
201
|
+
assert.notEqual(appLog.isError, true, `GetApplicationLog returned an MCP tool error: ${textPayload(appLog)}`);
|
|
202
|
+
});
|
|
@@ -100,6 +100,17 @@ test('builds SAP development companion MCP entries', () => {
|
|
|
100
100
|
assert.throws(() => buildSapDevelopmentMcpEntries(['missing-tool']), /Unknown SAP development MCP server id/);
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
test('configures the HANA inspector without persisting credentials', () => {
|
|
104
|
+
const entry = buildSapDevelopmentMcpEntries(['hana-cloud-inspector'])['hana-cloud-inspector'];
|
|
105
|
+
assert.equal(entry.command, 'npx');
|
|
106
|
+
assert.deepEqual(entry.args, ['--yes', '--ignore-scripts', '--package=sap-ai-dev-toolkit', 'sap-ai-hana']);
|
|
107
|
+
assert.equal(entry.BAS_EXT_KIND, 'sap-development-companion');
|
|
108
|
+
assert.equal(entry.displayName, 'HANA Cloud inspector');
|
|
109
|
+
assert.equal(entry.env, undefined);
|
|
110
|
+
assert.doesNotMatch(JSON.stringify(entry), /"(?:HANA_RO_PASSWORD|VCAP_SERVICES)"\s*:/);
|
|
111
|
+
assert.doesNotMatch(JSON.stringify(entry), /"(?:password|pwd)"\s*:/i);
|
|
112
|
+
});
|
|
113
|
+
|
|
103
114
|
test('installs and removes SAP development companion entries', async t => {
|
|
104
115
|
const directory = await mkdtemp(join(tmpdir(), 'bas-mcp-tools-config-'));
|
|
105
116
|
const path = join(directory, 'mcp.json');
|
|
@@ -120,6 +131,35 @@ test('installs and removes SAP development companion entries', async t => {
|
|
|
120
131
|
assert.deepEqual(Object.keys(cleared.servers), ['userServer']);
|
|
121
132
|
});
|
|
122
133
|
|
|
134
|
+
test('installs and removes the HANA inspector without changing unrelated MCP servers', async t => {
|
|
135
|
+
const directory = await mkdtemp(join(tmpdir(), 'bas-mcp-hana-config-'));
|
|
136
|
+
const path = join(directory, 'mcp.json');
|
|
137
|
+
const initial = {
|
|
138
|
+
custom: true,
|
|
139
|
+
servers: { userServer: { type: 'stdio', command: 'custom-server' } }
|
|
140
|
+
};
|
|
141
|
+
await writeFile(path, JSON.stringify(initial));
|
|
142
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
143
|
+
|
|
144
|
+
const installed = await installMcpConfig([], {
|
|
145
|
+
env: { H2O_URL: 'http://h2o.example' },
|
|
146
|
+
path,
|
|
147
|
+
sapDevelopmentServers: ['hana-cloud-inspector']
|
|
148
|
+
});
|
|
149
|
+
assert.deepEqual(Object.keys(installed.servers), ['hana-cloud-inspector']);
|
|
150
|
+
const configured = await readMcpConfig(path);
|
|
151
|
+
assert.deepEqual(Object.keys(configured.servers).sort(), ['hana-cloud-inspector', 'userServer']);
|
|
152
|
+
assert.equal(configured.custom, true);
|
|
153
|
+
assert.equal(configured.servers['hana-cloud-inspector'].env, undefined);
|
|
154
|
+
assert.doesNotMatch(JSON.stringify(configured), /"(?:HANA_RO_PASSWORD|VCAP_SERVICES)"\s*:/);
|
|
155
|
+
assert.doesNotMatch(JSON.stringify(configured), /"(?:password|pwd)"\s*:/i);
|
|
156
|
+
|
|
157
|
+
await installMcpConfig([], { env: { H2O_URL: 'http://h2o.example' }, path });
|
|
158
|
+
const removed = await readMcpConfig(path);
|
|
159
|
+
assert.deepEqual(removed.servers, initial.servers);
|
|
160
|
+
assert.equal(removed.custom, true);
|
|
161
|
+
});
|
|
162
|
+
|
|
123
163
|
test('installs and removes CF entries without deleting unrelated MCP servers', async t => {
|
|
124
164
|
const directory = await mkdtemp(join(tmpdir(), 'bas-mcp-cf-config-'));
|
|
125
165
|
const path = join(directory, 'mcp.json');
|
package/test/mcp-proxy.test.mjs
CHANGED
|
@@ -5,24 +5,13 @@ import { tmpdir } from 'node:os';
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { once } from 'node:events';
|
|
8
|
-
import { childArguments, MCPProxy } from '../src/mcp-proxy.mjs';
|
|
8
|
+
import { childArguments, MCPProxy, PUBLIC_VSP_TOOLS } from '../src/mcp-proxy.mjs';
|
|
9
9
|
import { PassThrough } from 'node:stream';
|
|
10
10
|
|
|
11
11
|
const fixture = fileURLToPath(new URL('./fixtures/fake-vsp.mjs', import.meta.url));
|
|
12
|
-
const
|
|
13
|
-
'AnalyzeABAPCode', '
|
|
14
|
-
'
|
|
15
|
-
'GetConnectionInfo', 'GetObjectStructure', 'GetTypeHierarchy', 'GetTypeInfo',
|
|
16
|
-
'GrepObject', 'GrepPackage', 'CallRFC', 'DebuggerAttach', 'DebuggerDetach',
|
|
17
|
-
'DebuggerGetStack', 'DebuggerGetVariables', 'DebuggerListen', 'DebuggerStep',
|
|
18
|
-
'DeleteBreakpoint', 'GetBreakpoints', 'GetDump', 'GetSQLTraceState', 'GetTrace',
|
|
19
|
-
'ListDumps', 'SetBreakpoint', 'CloneObject', 'CreateAndActivateProgram',
|
|
20
|
-
'CreateClassWithTests', 'CreateObject', 'CreateTestInclude', 'DeleteObject',
|
|
21
|
-
'ExecuteABAP', 'GetClass', 'GetClassComponents', 'GetClassInclude', 'GetFunction',
|
|
22
|
-
'GetInclude', 'GetInterface', 'GetProgram', 'GetStructure', 'GetTransaction',
|
|
23
|
-
'LockObject', 'MoveObject', 'RecoverFailedCreate', 'RenameObject', 'SaveToFile',
|
|
24
|
-
'UnlockObject', 'UpdateClassInclude', 'UpdateSource', 'WriteClass', 'WriteProgram',
|
|
25
|
-
'CreateTransport', 'ListDependencies', 'PublishServiceBinding', 'UnpublishServiceBinding'
|
|
12
|
+
const HIDDEN_VSP_TOOLS = [
|
|
13
|
+
'AnalyzeABAPCode', 'DebuggerListen', 'DeleteBreakpoint', 'DeleteObject', 'ExecuteABAP', 'ReleaseTransport',
|
|
14
|
+
'DeleteTransport', 'SAP', 'ListSQLTraces', 'PublishServiceBinding', 'UnpublishServiceBinding'
|
|
26
15
|
];
|
|
27
16
|
|
|
28
17
|
|
|
@@ -166,22 +155,23 @@ test('merges paged tools and routes calls to the selected child', async t => {
|
|
|
166
155
|
assert.deepEqual(lintTool.inputSchema.required, ['files']);
|
|
167
156
|
assert.deepEqual(lintTool.inputSchema.properties.files.items.required, ['filename', 'source']);
|
|
168
157
|
|
|
158
|
+
const localWorkflowTools = [
|
|
159
|
+
'LintABAP', 'GetApplicationLog', 'PrepareABAPChangeSet', 'ApplyABAPChangeSet',
|
|
160
|
+
'CheckTransportReadiness', 'PlanABAPCloudMigration', 'GenerateRAPRegressionSuite', 'RunRAPRegressionSuite'
|
|
161
|
+
];
|
|
169
162
|
for (const destination of ['alpha', 'beta']) {
|
|
170
|
-
for (const toolName of
|
|
171
|
-
assert.ok(names.has(`${destination}__${toolName}`), `${destination} exposes ${toolName}`);
|
|
163
|
+
for (const toolName of PUBLIC_VSP_TOOLS) {
|
|
164
|
+
assert.ok(names.has(`${destination}__${toolName}`), `${destination} exposes curated ${toolName}`);
|
|
172
165
|
}
|
|
173
|
-
for (const
|
|
174
|
-
|
|
175
|
-
'GetFeatures', 'GetAPIReleaseState', 'PrettyPrint', 'ListTransports',
|
|
176
|
-
'GetTransport', 'GetUserTransports', 'GetTransportInfo',
|
|
177
|
-
'GetApplicationLog', 'ActivateMultiple'
|
|
178
|
-
]) {
|
|
179
|
-
assert.ok(names.has(`${destination}__${coreTool}`), `${destination} exposes ${coreTool}`);
|
|
166
|
+
for (const toolName of localWorkflowTools) {
|
|
167
|
+
assert.ok(names.has(`${destination}__${toolName}`), `${destination} exposes local ${toolName}`);
|
|
180
168
|
}
|
|
181
|
-
for (const
|
|
182
|
-
assert.
|
|
169
|
+
for (const hiddenTool of HIDDEN_VSP_TOOLS) {
|
|
170
|
+
assert.equal(names.has(`${destination}__${hiddenTool}`), false, `${destination} hides ${hiddenTool}`);
|
|
183
171
|
}
|
|
184
172
|
}
|
|
173
|
+
assert.equal(listed.result.tools.filter(tool => tool.name.startsWith('alpha__')).length, PUBLIC_VSP_TOOLS.size + localWorkflowTools.length);
|
|
174
|
+
assert.equal(PUBLIC_VSP_TOOLS.size + localWorkflowTools.length, 59);
|
|
185
175
|
|
|
186
176
|
const requiredArguments = {
|
|
187
177
|
GetFeatures: [],
|
|
@@ -234,11 +224,7 @@ test('merges paged tools and routes calls to the selected child', async t => {
|
|
|
234
224
|
params: { type: 'delete' }
|
|
235
225
|
}],
|
|
236
226
|
['ActivateMultiple', { objects: ['PROG ZDEMO'] }],
|
|
237
|
-
['CreateTransport', {}]
|
|
238
|
-
['ReleaseTransport', {}],
|
|
239
|
-
['DeleteTransport', {}],
|
|
240
|
-
['ListSQLTraces', {}],
|
|
241
|
-
['SAP', { action: 'analyze', params: { type: 'application_log' } }]
|
|
227
|
+
['CreateTransport', {}]
|
|
242
228
|
];
|
|
243
229
|
for (const [index, [name, arguments_]] of additionalCalls.entries()) {
|
|
244
230
|
const response = await proxy.handle({
|
package/test/setup-cf.test.mjs
CHANGED
|
@@ -6,6 +6,8 @@ import { join } from 'node:path';
|
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { installMcpConfig } from '../src/mcp-config.mjs';
|
|
8
8
|
import { destinationTable } from '../scripts/postinstall.mjs';
|
|
9
|
+
import { readCredentials, resolveCredentialsPath, storeDestinationCredentials } from '../src/credentials-store.mjs';
|
|
10
|
+
import { credentialKeyForDestination } from '../src/credential-overrides.mjs';
|
|
9
11
|
import { spawnWithPty } from './pty.mjs';
|
|
10
12
|
|
|
11
13
|
const setupModule = fileURLToPath(new URL('../src/setup.mjs', import.meta.url));
|
|
@@ -28,6 +30,22 @@ function cloudFoundryDestination({ name, serverName, instanceGuid, instanceName,
|
|
|
28
30
|
};
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
function cloudFoundryOnPremiseDestination() {
|
|
34
|
+
const destination = cloudFoundryDestination({
|
|
35
|
+
name: 'shared-name',
|
|
36
|
+
serverName: 'cf:space-one:cf-destination:shared-name',
|
|
37
|
+
instanceGuid: 'cf-destination',
|
|
38
|
+
instanceName: 'destination-service',
|
|
39
|
+
keyName: 'destination-key'
|
|
40
|
+
});
|
|
41
|
+
destination.authentication = 'BasicAuthentication';
|
|
42
|
+
destination.proxyType = 'OnPremise';
|
|
43
|
+
destination.cf.connectivityInstanceGuid = 'cf-connectivity';
|
|
44
|
+
destination.cf.connectivityInstanceName = 'connectivity-service';
|
|
45
|
+
destination.cf.connectivityKeyName = 'connectivity-key';
|
|
46
|
+
return destination;
|
|
47
|
+
}
|
|
48
|
+
|
|
31
49
|
async function makeFixture(t, { version = 'cf version 8.18.0', space = 'space-one', deleteMode = 'success' } = {}) {
|
|
32
50
|
const directory = await mkdtemp(join(tmpdir(), 'bas-cf-setup-'));
|
|
33
51
|
const bin = join(directory, 'bin');
|
|
@@ -63,6 +81,7 @@ await runSetup({
|
|
|
63
81
|
await writeFile(process.env.CF_DISCOVERY_LOG, JSON.stringify({ spaceGuid, managedKeys }));
|
|
64
82
|
return JSON.parse(process.env.TEST_CF_RESULT);
|
|
65
83
|
},
|
|
84
|
+
verifyOnPremCredentialRoute: async () => JSON.parse(process.env.TEST_ONPREM_CSRF_RESULT),
|
|
66
85
|
...(process.env.TEST_INSTALL_FAIL === 'true' ? {
|
|
67
86
|
install: async () => { throw new Error('simulated config write failure'); }
|
|
68
87
|
} : {})
|
|
@@ -81,13 +100,14 @@ await runSetup({
|
|
|
81
100
|
TEST_CF_DELETE_MODE: deleteMode,
|
|
82
101
|
TEST_INSTALL_FAIL: 'false',
|
|
83
102
|
TEST_BAS_DESTINATIONS: JSON.stringify([]),
|
|
84
|
-
TEST_CF_RESULT: JSON.stringify({ destinations: [], createdKeys: [], warnings: [] })
|
|
103
|
+
TEST_CF_RESULT: JSON.stringify({ destinations: [], createdKeys: [], warnings: [] }),
|
|
104
|
+
TEST_ONPREM_CSRF_RESULT: JSON.stringify({ httpStatus: 200, tokenReceived: true, cookieCount: 1 })
|
|
85
105
|
};
|
|
86
106
|
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
87
107
|
return { directory, configPath, callLog, discoveryLog, harness, env };
|
|
88
108
|
}
|
|
89
109
|
|
|
90
|
-
function runSetupInPty(fixture, { importAnswer = 'y\r', selection = ' \r' } = {}) {
|
|
110
|
+
function runSetupInPty(fixture, { importAnswer = 'y\r', selection = ' \r', promptAnswers = [] } = {}) {
|
|
91
111
|
return new Promise((resolve, reject) => {
|
|
92
112
|
const command = `${JSON.stringify(process.execPath)} ${JSON.stringify(fixture.harness)}`;
|
|
93
113
|
const child = spawnWithPty(command, { env: fixture.env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
@@ -95,6 +115,7 @@ function runSetupInPty(fixture, { importAnswer = 'y\r', selection = ' \r' } = {}
|
|
|
95
115
|
let stderr = '';
|
|
96
116
|
let importSent = false;
|
|
97
117
|
let selectionSent = false;
|
|
118
|
+
let promptAnswerIndex = 0;
|
|
98
119
|
const timeout = setTimeout(() => child.kill('SIGKILL'), 15000);
|
|
99
120
|
child.stdout.on('data', chunk => {
|
|
100
121
|
stdout += chunk.toString();
|
|
@@ -104,7 +125,16 @@ function runSetupInPty(fixture, { importAnswer = 'y\r', selection = ' \r' } = {}
|
|
|
104
125
|
}
|
|
105
126
|
if (!selectionSent && stdout.includes('Select destinations')) {
|
|
106
127
|
selectionSent = true;
|
|
107
|
-
child.stdin.
|
|
128
|
+
if (promptAnswers.length) child.stdin.write(selection);
|
|
129
|
+
else child.stdin.end(selection);
|
|
130
|
+
}
|
|
131
|
+
if (selectionSent && promptAnswerIndex < promptAnswers.length) {
|
|
132
|
+
const answer = promptAnswers[promptAnswerIndex];
|
|
133
|
+
if (stdout.includes(answer.when)) {
|
|
134
|
+
promptAnswerIndex++;
|
|
135
|
+
child.stdin.write(answer.value);
|
|
136
|
+
if (promptAnswerIndex === promptAnswers.length) child.stdin.end();
|
|
137
|
+
}
|
|
108
138
|
}
|
|
109
139
|
});
|
|
110
140
|
child.stderr.on('data', chunk => { stderr += chunk.toString(); });
|
|
@@ -114,7 +144,7 @@ function runSetupInPty(fixture, { importAnswer = 'y\r', selection = ' \r' } = {}
|
|
|
114
144
|
});
|
|
115
145
|
child.on('exit', (code, signal) => {
|
|
116
146
|
clearTimeout(timeout);
|
|
117
|
-
resolve({ code, signal, stdout, stderr, importSent, selectionSent });
|
|
147
|
+
resolve({ code, signal, stdout, stderr, importSent, selectionSent, promptAnswersSent: promptAnswerIndex });
|
|
118
148
|
});
|
|
119
149
|
});
|
|
120
150
|
}
|
|
@@ -145,6 +175,7 @@ test('imports selected CF destinations, passes managed key references, and remov
|
|
|
145
175
|
assert.equal(result.selectionSent, true, result.stdout);
|
|
146
176
|
assert.match(result.stdout, /CF new-destination-service/);
|
|
147
177
|
assert.match(result.stdout, /Configured 1 MCP server/);
|
|
178
|
+
assert.match(result.stdout, /GitHub Copilot Chat.*Chat tools picker.*enable each selected destination server/s);
|
|
148
179
|
assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /SECRET|clientSecret|Password/);
|
|
149
180
|
|
|
150
181
|
const snapshot = JSON.parse(await readFile(fixture.discoveryLog, 'utf8'));
|
|
@@ -163,6 +194,102 @@ test('imports selected CF destinations, passes managed key references, and remov
|
|
|
163
194
|
assert.equal(calls.some(args => args.at(-1) === 'new-key'), false);
|
|
164
195
|
});
|
|
165
196
|
|
|
197
|
+
test('prompts for BAS and CF OnPremise overrides separately and keeps their routes distinct', async t => {
|
|
198
|
+
const fixture = await makeFixture(t);
|
|
199
|
+
const cfDestination = cloudFoundryOnPremiseDestination();
|
|
200
|
+
fixture.env.TEST_BAS_DESTINATIONS = JSON.stringify([
|
|
201
|
+
{
|
|
202
|
+
name: 'internet-system', url: 'http://internet-system.dest', backendUrl: 'https://internet.example:44300',
|
|
203
|
+
client: '100', authentication: 'BasicAuthentication', proxyType: 'Internet',
|
|
204
|
+
probe: { status: 'available', available: true }
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: 'shared-name', url: 'http://shared-name.dest', backendUrl: 'https://internal.example:44300',
|
|
208
|
+
client: '101', authentication: 'BasicAuthentication', proxyType: 'OnPremise',
|
|
209
|
+
probe: { status: 'available', available: true }
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
name: 'principal-system', url: 'http://principal-system.dest',
|
|
213
|
+
client: '102', authentication: 'PrincipalPropagation', proxyType: 'OnPremise',
|
|
214
|
+
probe: { status: 'available', available: true }
|
|
215
|
+
}
|
|
216
|
+
]);
|
|
217
|
+
fixture.env.TEST_CF_RESULT = JSON.stringify({ destinations: [cfDestination], createdKeys: [], warnings: [] });
|
|
218
|
+
|
|
219
|
+
const result = await runSetupInPty(fixture, {
|
|
220
|
+
selection: 'a\r',
|
|
221
|
+
promptAnswers: [
|
|
222
|
+
{ when: 'BAS Internet — internet-system — override SAP credentials?', value: 'y\r' },
|
|
223
|
+
{ when: 'BAS Internet — internet-system — SAP user', value: 'INTERNET_USER\r' },
|
|
224
|
+
{ when: 'BAS Internet — internet-system — SAP password', value: 'internet-password-secret\r' },
|
|
225
|
+
{ when: 'BAS OnPremise — shared-name — override SAP credentials?', value: 'y\r' },
|
|
226
|
+
{ when: 'BAS OnPremise — shared-name — SAP user', value: 'BAS_CC_USER\r' },
|
|
227
|
+
{ when: 'BAS OnPremise — shared-name — SAP password', value: 'bas-cc-password-secret\r' },
|
|
228
|
+
{ when: 'Cloud Foundry OnPremise — shared-name — override SAP credentials?', value: 'y\r' },
|
|
229
|
+
{ when: 'Cloud Foundry OnPremise — shared-name — SAP user', value: 'CF_CC_USER\r' },
|
|
230
|
+
{ when: 'Cloud Foundry OnPremise — shared-name — SAP password', value: 'cf-cc-password-secret\r' }
|
|
231
|
+
]
|
|
232
|
+
});
|
|
233
|
+
assert.equal(result.code, 0, `${result.stdout}\n${result.stderr}`);
|
|
234
|
+
assert.equal(result.promptAnswersSent, 9, result.stdout);
|
|
235
|
+
assert.doesNotMatch(result.stdout, /PrincipalPropagation — principal-system — override SAP credentials/);
|
|
236
|
+
assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /internet-password-secret|bas-cc-password-secret|cf-cc-password-secret/);
|
|
237
|
+
|
|
238
|
+
const credentialsPath = await resolveCredentialsPath(fixture.env, fixture.configPath);
|
|
239
|
+
const stored = (await readCredentials(credentialsPath)).destinations;
|
|
240
|
+
assert.equal(stored['internet-system'].mode, 'direct');
|
|
241
|
+
assert.equal(stored['internet-system'].host, 'https://internet.example:44300');
|
|
242
|
+
assert.equal(stored['shared-name'].mode, 'bas-tunnel');
|
|
243
|
+
assert.equal(stored['shared-name'].host, undefined, 'BAS OnPremise must not store or use the backend URL');
|
|
244
|
+
const cfKey = credentialKeyForDestination(cfDestination);
|
|
245
|
+
assert.equal(stored[cfKey].mode, 'cf-connectivity');
|
|
246
|
+
assert.equal(stored[cfKey].host, undefined);
|
|
247
|
+
assert.deepEqual(
|
|
248
|
+
[stored['shared-name'].user, stored[cfKey].user],
|
|
249
|
+
['BAS_CC_USER', 'CF_CC_USER'],
|
|
250
|
+
'same destination names from BAS and CF must remain distinct'
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const mcpConfig = JSON.parse(await readFile(fixture.configPath, 'utf8'));
|
|
254
|
+
assert.equal(JSON.stringify(mcpConfig).includes('bas-cc-password-secret'), false);
|
|
255
|
+
assert.equal(JSON.stringify(mcpConfig).includes('cf-cc-password-secret'), false);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('No removes a stored BAS OnPremise override and failed CSRF preflight refuses a new one', async t => {
|
|
259
|
+
const fixture = await makeFixture(t);
|
|
260
|
+
fixture.env.TEST_BAS_DESTINATIONS = JSON.stringify([{
|
|
261
|
+
name: 'shared-name', url: 'http://shared-name.dest', backendUrl: 'https://internal.example:44300',
|
|
262
|
+
client: '100', authentication: 'BasicAuthentication', proxyType: 'OnPremise',
|
|
263
|
+
probe: { status: 'available', available: true }
|
|
264
|
+
}]);
|
|
265
|
+
fixture.env.TEST_CF_RESULT = JSON.stringify({ destinations: [], createdKeys: [], warnings: [] });
|
|
266
|
+
const credentialsPath = await resolveCredentialsPath(fixture.env, fixture.configPath);
|
|
267
|
+
await storeDestinationCredentials(credentialsPath, 'shared-name', {
|
|
268
|
+
user: 'OLD_USER', password: 'old-password-secret', mode: 'bas-tunnel'
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const declined = await runSetupInPty(fixture, {
|
|
272
|
+
selection: ' \r',
|
|
273
|
+
promptAnswers: [{ when: 'BAS OnPremise — shared-name — override SAP credentials?', value: 'n\r' }]
|
|
274
|
+
});
|
|
275
|
+
assert.equal(declined.code, 0, `${declined.stdout}\n${declined.stderr}`);
|
|
276
|
+
assert.equal((await readCredentials(credentialsPath)).destinations['shared-name'], undefined);
|
|
277
|
+
|
|
278
|
+
fixture.env.TEST_ONPREM_CSRF_RESULT = JSON.stringify({ httpStatus: 200, tokenReceived: true, cookieCount: 0 });
|
|
279
|
+
const rejected = await runSetupInPty(fixture, {
|
|
280
|
+
selection: ' \r',
|
|
281
|
+
promptAnswers: [
|
|
282
|
+
{ when: 'BAS OnPremise — shared-name — override SAP credentials?', value: 'y\r' },
|
|
283
|
+
{ when: 'BAS OnPremise — shared-name — SAP user', value: 'NEW_USER\r' },
|
|
284
|
+
{ when: 'BAS OnPremise — shared-name — SAP password', value: 'new-password-secret\r' }
|
|
285
|
+
]
|
|
286
|
+
});
|
|
287
|
+
assert.equal(rejected.code, 0, `${rejected.stdout}\n${rejected.stderr}`);
|
|
288
|
+
assert.match(rejected.stdout, /did not return both a CSRF token and session cookie/);
|
|
289
|
+
assert.equal((await readCredentials(credentialsPath)).destinations['shared-name'], undefined);
|
|
290
|
+
assert.doesNotMatch(`${rejected.stdout}\n${rejected.stderr}`, /new-password-secret/);
|
|
291
|
+
});
|
|
292
|
+
|
|
166
293
|
test('declining CF import keeps BAS setup and does not create CF keys', async t => {
|
|
167
294
|
const fixture = await makeFixture(t);
|
|
168
295
|
fixture.env.TEST_BAS_DESTINATIONS = JSON.stringify([{
|
package/test/setup.test.mjs
CHANGED
|
@@ -70,7 +70,7 @@ function runPostinstallInPty(env, keys, assetsAnswer = '\r') {
|
|
|
70
70
|
selectionSent = true;
|
|
71
71
|
child.stdin.write(keys);
|
|
72
72
|
}
|
|
73
|
-
if (!assetsAnswerSent && stdout.includes('Install the bundled agents and
|
|
73
|
+
if (!assetsAnswerSent && stdout.includes('Install the bundled agents and all skills')) {
|
|
74
74
|
assetsAnswerSent = true;
|
|
75
75
|
child.stdin.write(assetsAnswer);
|
|
76
76
|
}
|
|
@@ -90,6 +90,7 @@ function runPostinstallInPty(env, keys, assetsAnswer = '\r') {
|
|
|
90
90
|
async function assertUserCopilotAssets(home) {
|
|
91
91
|
const copilotRoot = join(home, '.copilot');
|
|
92
92
|
assert.equal((await stat(join(copilotRoot, 'agents', 'abap-developer.agent.md'))).isFile(), true);
|
|
93
|
+
assert.equal((await stat(join(copilotRoot, 'agents', 'hana-cloud-hdi-specialist.agent.md'))).isFile(), true);
|
|
93
94
|
const skillNames = (await readdir(join(copilotRoot, 'skills'))).sort();
|
|
94
95
|
assert.deepEqual(skillNames, [
|
|
95
96
|
'abap-debugging',
|
|
@@ -98,6 +99,9 @@ async function assertUserCopilotAssets(home) {
|
|
|
98
99
|
'abap-testing-quality',
|
|
99
100
|
'cds-development',
|
|
100
101
|
'clean-core-extensibility',
|
|
102
|
+
'hana-cloud-inspection',
|
|
103
|
+
'hana-cloud-native-development',
|
|
104
|
+
'hana-cloud-validation',
|
|
101
105
|
'rap-development',
|
|
102
106
|
'rap-service-delivery',
|
|
103
107
|
'sap-sdlc-orchestration',
|
|
@@ -279,13 +283,13 @@ test('global postinstall completes BAS selection before optional Copilot assets'
|
|
|
279
283
|
const declineLogs = `${declined.stdout}\n${declined.stderr}`;
|
|
280
284
|
assert.match(declineLogs, /Configured 0 MCP servers/);
|
|
281
285
|
assert.match(declineLogs, /\[Y\/n\]/);
|
|
282
|
-
assert.match(declineLogs, /Press Enter to install the bundled agents and
|
|
286
|
+
assert.match(declineLogs, /Press Enter to install the bundled agents and all skills in the path shown below; type n then press Enter to skip/);
|
|
283
287
|
assert.match(declineLogs, /🤖 sap-ai-dev-toolkit/);
|
|
284
288
|
assert.match(declineLogs, /\u001b\[1;35m/);
|
|
285
289
|
assert.match(declineLogs, /Optional Copilot setup is waiting for your choice/);
|
|
286
290
|
assert.match(declineLogs, /Copilot agent and skills were skipped\. Your files were not changed/);
|
|
287
291
|
assert.ok(declineLogs.indexOf('Optional Copilot setup is waiting for your choice') > declineLogs.indexOf('Configured 0 MCP servers'), declineLogs);
|
|
288
|
-
assert.ok(declineLogs.indexOf('Install the bundled agents and
|
|
292
|
+
assert.ok(declineLogs.indexOf('Install the bundled agents and all skills') > declineLogs.indexOf('Optional Copilot setup is waiting for your choice'), declineLogs);
|
|
289
293
|
assert.ok(declineLogs.indexOf('No MCP server entries are configured for this add-on.') > declineLogs.indexOf('Copilot agent and skills were skipped'), declineLogs);
|
|
290
294
|
assert.ok(declineLogs.includes(`MCP config file: ${config}`), declineLogs);
|
|
291
295
|
const configAfterDecline = JSON.parse(await readFile(config, 'utf8'));
|
|
@@ -297,9 +301,9 @@ test('global postinstall completes BAS selection before optional Copilot assets'
|
|
|
297
301
|
assert.equal(accepted.selectionSent, true, accepted.stdout);
|
|
298
302
|
assert.equal(accepted.assetsAnswerSent, true, accepted.stdout);
|
|
299
303
|
const acceptLogs = `${accepted.stdout}\n${accepted.stderr}`;
|
|
300
|
-
assert.ok(acceptLogs.indexOf('Install the bundled agents and
|
|
301
|
-
assert.match(acceptLogs, /Installed
|
|
302
|
-
assert.ok(acceptLogs.indexOf('Installation configuration summary') > acceptLogs.indexOf('Installed
|
|
304
|
+
assert.ok(acceptLogs.indexOf('Install the bundled agents and all skills') > acceptLogs.indexOf('Configured 1 MCP server'), acceptLogs);
|
|
305
|
+
assert.match(acceptLogs, /Installed 19 Copilot files/);
|
|
306
|
+
assert.ok(acceptLogs.indexOf('Installation configuration summary') > acceptLogs.indexOf('Installed 19 Copilot files'), acceptLogs);
|
|
303
307
|
assert.ok(acceptLogs.includes(`MCP config file: ${config}`), acceptLogs);
|
|
304
308
|
assert.ok(acceptLogs.includes('Destination: BAS · alpha-system · client 100 · Basic'), acceptLogs);
|
|
305
309
|
assert.ok(acceptLogs.includes('Launch: stdio · sap-ai-dev'), acceptLogs);
|