sap-ai-dev-toolkit 0.2.5
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 +25 -0
- package/.github/agents/abap-runtime-debugger.agent.md +27 -0
- package/.github/agents/rap-service-developer.agent.md +27 -0
- package/.github/agents/sap-solution-architect.agent.md +31 -0
- package/.github/skills/abap-debugging/SKILL.md +15 -0
- package/.github/skills/abap-development/SKILL.md +16 -0
- package/.github/skills/abap-runtime-analysis/SKILL.md +17 -0
- package/.github/skills/abap-testing-quality/SKILL.md +19 -0
- package/.github/skills/cds-development/SKILL.md +16 -0
- package/.github/skills/clean-core-extensibility/SKILL.md +16 -0
- package/.github/skills/rap-development/SKILL.md +16 -0
- package/.github/skills/rap-service-delivery/SKILL.md +17 -0
- package/.github/skills/sap-sdlc-orchestration/SKILL.md +16 -0
- package/.github/skills/sap-standard-api-analysis/SKILL.md +16 -0
- package/.github/skills/sap-transport-release/SKILL.md +14 -0
- package/LICENSE +22 -0
- package/LICENSE-APACHE-2.0.txt +201 -0
- package/NOTICE +33 -0
- package/README.md +723 -0
- package/dist/checksums.txt +5 -0
- 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/package.json +52 -0
- package/patches/vsp-bas-proxy-auth.patch +310 -0
- package/scripts/build-vsp.mjs +52 -0
- package/scripts/ensure-go.mjs +59 -0
- package/scripts/install-user-copilot-assets.mjs +110 -0
- package/scripts/mutation-terminal-ui.mjs +69 -0
- package/scripts/postinstall.mjs +269 -0
- package/scripts/publish-npm.mjs +97 -0
- package/src/abaplint.mjs +105 -0
- package/src/bas-discovery.mjs +210 -0
- package/src/binary.mjs +99 -0
- package/src/cf-connectivity.mjs +317 -0
- package/src/cf-destination.mjs +793 -0
- package/src/launcher.mjs +144 -0
- package/src/mcp-config.mjs +253 -0
- package/src/mcp-proxy.mjs +388 -0
- package/src/setup.mjs +275 -0
- package/src/terminal-ui.mjs +67 -0
- package/test/cf-connectivity.test.mjs +287 -0
- package/test/cf-destination.test.mjs +307 -0
- package/test/cf-runtime.test.mjs +505 -0
- package/test/copilot-assets.test.mjs +37 -0
- package/test/copilot-content.test.mjs +145 -0
- package/test/discovery.test.mjs +199 -0
- package/test/fixtures/fake-vsp.mjs +244 -0
- package/test/launcher.test.mjs +164 -0
- package/test/mcp-config-cf.test.mjs +149 -0
- package/test/mcp-proxy.test.mjs +337 -0
- package/test/setup-cf.test.mjs +362 -0
- package/test/setup.test.mjs +376 -0
- package/test/terminal-ui.test.mjs +47 -0
- package/tools.md +68 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
2
|
+
import { once } from 'node:events';
|
|
3
|
+
import { sanitizeChildEnv, slugifyDestination } from './bas-discovery.mjs';
|
|
4
|
+
import { ABAP_LINT_TOOL, runABAPLint } from './abaplint.mjs';
|
|
5
|
+
|
|
6
|
+
const JSONRPC = '2.0';
|
|
7
|
+
const FORWARDED_METHODS = new Set(['ping', 'resources/list', 'resources/read', 'resources/templates/list', 'prompts/list', 'completion/complete', 'logging/setLevel']);
|
|
8
|
+
const APPLICATION_LOG_PARAMETERS = [
|
|
9
|
+
'program',
|
|
10
|
+
'user',
|
|
11
|
+
'object',
|
|
12
|
+
'subobject',
|
|
13
|
+
'from',
|
|
14
|
+
'to',
|
|
15
|
+
'max_results',
|
|
16
|
+
'messages'
|
|
17
|
+
];
|
|
18
|
+
const APPLICATION_LOG_SCHEMA = {
|
|
19
|
+
type: 'object',
|
|
20
|
+
properties: {
|
|
21
|
+
program: { type: 'string', description: 'ABAP program recorded in the log.' },
|
|
22
|
+
user: { type: 'string', description: 'SAP user recorded in the log.' },
|
|
23
|
+
object: { type: 'string', description: 'SLG1 application-log object.' },
|
|
24
|
+
subobject: { type: 'string', description: 'SLG1 application-log subobject.' },
|
|
25
|
+
from: { type: 'string', description: 'Inclusive lower date/time: YYYY-MM-DD, YYYYMMDD, or timestamp.' },
|
|
26
|
+
to: { type: 'string', description: 'Inclusive upper date/time; a date-only value includes the full day.' },
|
|
27
|
+
max_results: { type: 'number', description: 'Maximum log entries to return; defaults to 100.' },
|
|
28
|
+
messages: { type: 'boolean', description: 'Include BALDAT message details and T100 texts instead of headers only.' }
|
|
29
|
+
},
|
|
30
|
+
required: []
|
|
31
|
+
};
|
|
32
|
+
const APPLICATION_LOG_DESCRIPTION = 'Read SAP application log (SLG1) entries. Results are newest first and limited to 100 by default; set messages=true to include log message details.';
|
|
33
|
+
|
|
34
|
+
function applicationLogArguments(arguments_ = {}) {
|
|
35
|
+
const filters = arguments_ && typeof arguments_ === 'object' && !Array.isArray(arguments_) ? arguments_ : {};
|
|
36
|
+
const params = { type: 'application_log' };
|
|
37
|
+
for (const key of APPLICATION_LOG_PARAMETERS) {
|
|
38
|
+
if (Object.hasOwn(filters, key)) params[key] = filters[key];
|
|
39
|
+
}
|
|
40
|
+
return { action: 'analyze', params };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function rpcResult(id, result) { return { jsonrpc: JSONRPC, id, result }; }
|
|
44
|
+
function rpcError(id, code, message, data) { return { jsonrpc: JSONRPC, id, error: { code, message, ...(data === undefined ? {} : { data }) } }; }
|
|
45
|
+
function redactText(text) { return String(text).replace(/(authorization|cookie|password|secret|token)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]'); }
|
|
46
|
+
function diagnosticText(text) { return redactText(text || 'unknown error').replace(/\s+/g, ' ').slice(0, 500); }
|
|
47
|
+
|
|
48
|
+
const closedRoutes = new WeakMap();
|
|
49
|
+
|
|
50
|
+
function closeDestinationRoute(destination) {
|
|
51
|
+
if (!destination || typeof destination.close !== 'function') return Promise.resolve();
|
|
52
|
+
if (!closedRoutes.has(destination)) {
|
|
53
|
+
const closing = Promise.resolve().then(() => destination.close()).catch(() => {});
|
|
54
|
+
closedRoutes.set(destination, closing);
|
|
55
|
+
}
|
|
56
|
+
return closedRoutes.get(destination);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function childEnvironment(destination, env) {
|
|
60
|
+
return { ...sanitizeChildEnv(env), ...(destination.childEnv || {}) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
export function childArguments(destination, env = process.env) {
|
|
65
|
+
// The proxy allowlist keeps transport mutations to CreateTransport.
|
|
66
|
+
// Transportable source edits are controlled by SAP_ALLOW_TRANSPORTABLE_EDITS.
|
|
67
|
+
const mode = env.BAS_VSP_MODE || 'expert';
|
|
68
|
+
const args = ['--url', destination.url, '--client', destination.client || '001', '--mode', mode];
|
|
69
|
+
if (destination.source !== 'cloud-foundry' || destination.authentication === 'PrincipalPropagation') args.push('--proxy-auth');
|
|
70
|
+
args.push('--enable-transports');
|
|
71
|
+
return args;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
class Child {
|
|
75
|
+
constructor(binary, destination, options) {
|
|
76
|
+
this.destination = destination;
|
|
77
|
+
this.options = options;
|
|
78
|
+
this.pending = new Map();
|
|
79
|
+
this.nextId = 1;
|
|
80
|
+
this.buffer = '';
|
|
81
|
+
this.exited = false;
|
|
82
|
+
this.closing = false;
|
|
83
|
+
this.process = (options.spawn || nodeSpawn)(binary, options.args || childArguments(destination, options.env), {
|
|
84
|
+
env: childEnvironment(destination, options.env),
|
|
85
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
86
|
+
});
|
|
87
|
+
this.process.stdout.setEncoding('utf8');
|
|
88
|
+
this.process.stdout.on('data', chunk => this.onData(chunk));
|
|
89
|
+
this.process.stderr.setEncoding('utf8');
|
|
90
|
+
this.process.stderr.on('data', chunk => {
|
|
91
|
+
if (options.log) options.log(`[${destination.name}] ${redactText(chunk).trimEnd()}`);
|
|
92
|
+
});
|
|
93
|
+
this.process.on('error', error => {
|
|
94
|
+
if (!this.closing && !this.pending.size && this.options.log) this.options.log(`[${destination.name}] VSP child process error: ${diagnosticText(error.message)}`);
|
|
95
|
+
this.fail(error);
|
|
96
|
+
void closeDestinationRoute(destination);
|
|
97
|
+
});
|
|
98
|
+
this.process.on('exit', (code, signal) => {
|
|
99
|
+
if (!this.closing && !this.pending.size && this.options.log) this.options.log(`[${destination.name}] VSP child exited (${code ?? signal})`);
|
|
100
|
+
this.fail(new Error(`child exited (${code ?? signal})`));
|
|
101
|
+
void closeDestinationRoute(destination);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
onData(chunk) {
|
|
106
|
+
this.buffer += chunk;
|
|
107
|
+
let newline;
|
|
108
|
+
while ((newline = this.buffer.indexOf('\n')) >= 0) {
|
|
109
|
+
const line = this.buffer.slice(0, newline).trim();
|
|
110
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
111
|
+
if (!line) continue;
|
|
112
|
+
try { this.onMessage(JSON.parse(line)); } catch (error) { this.fail(new Error(`invalid child JSON-RPC response: ${error.message}`)); }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
onMessage(message) {
|
|
117
|
+
if (message.id === undefined || message.id === null) {
|
|
118
|
+
if (message.method?.startsWith('notifications/')) {
|
|
119
|
+
try { this.options.onNotification?.(message); }
|
|
120
|
+
catch (error) { this.options.log?.(`[${this.destination.name}] child notification forwarding failed: ${diagnosticText(error.message)}`); }
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const pending = this.pending.get(message.id);
|
|
125
|
+
if (!pending) return;
|
|
126
|
+
this.pending.delete(message.id);
|
|
127
|
+
if (message.error) pending.reject(Object.assign(new Error(message.error.message || 'child JSON-RPC error'), { rpcError: message.error }));
|
|
128
|
+
else pending.resolve(message);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
fail(error) {
|
|
132
|
+
if (this.exited) return;
|
|
133
|
+
this.exited = true;
|
|
134
|
+
for (const pending of this.pending.values()) pending.reject(error);
|
|
135
|
+
this.pending.clear();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
request(method, params) {
|
|
139
|
+
if (this.exited || !this.process.stdin.writable) return Promise.reject(new Error(`destination ${this.destination.name} child is not running`));
|
|
140
|
+
const id = this.nextId++;
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
this.pending.set(id, { resolve, reject });
|
|
143
|
+
try { this.process.stdin.write(`${JSON.stringify({ jsonrpc: JSONRPC, id, method, ...(params === undefined ? {} : { params })})}\n`); }
|
|
144
|
+
catch (error) { this.pending.delete(id); reject(error); }
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
notify(method, params) {
|
|
149
|
+
if (this.exited || !this.process.stdin.writable) return;
|
|
150
|
+
try { this.process.stdin.write(`${JSON.stringify({ jsonrpc: JSONRPC, method, ...(params === undefined ? {} : { params })})}\n`); } catch {}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async initialize(params) {
|
|
154
|
+
const response = await this.request('initialize', params);
|
|
155
|
+
this.notify('notifications/initialized');
|
|
156
|
+
return response.result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async listTools() {
|
|
160
|
+
const tools = [];
|
|
161
|
+
let cursor;
|
|
162
|
+
do {
|
|
163
|
+
const response = await this.request('tools/list', cursor ? { cursor } : {});
|
|
164
|
+
tools.push(...(response.result?.tools || []));
|
|
165
|
+
cursor = response.result?.nextCursor;
|
|
166
|
+
} while (cursor);
|
|
167
|
+
return tools;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async close() {
|
|
171
|
+
if (this.exited) return;
|
|
172
|
+
this.closing = true;
|
|
173
|
+
this.notify('notifications/cancelled', { reason: 'proxy shutdown' });
|
|
174
|
+
this.process.kill('SIGTERM');
|
|
175
|
+
let timeout;
|
|
176
|
+
try {
|
|
177
|
+
await Promise.race([
|
|
178
|
+
once(this.process, 'exit').catch(() => {}),
|
|
179
|
+
new Promise(resolve => { timeout = setTimeout(resolve, 1000); })
|
|
180
|
+
]);
|
|
181
|
+
} finally {
|
|
182
|
+
clearTimeout(timeout);
|
|
183
|
+
}
|
|
184
|
+
if (!this.exited) this.process.kill('SIGKILL');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export class MCPProxy {
|
|
189
|
+
constructor({ binary, destinations, env = process.env, spawn = nodeSpawn, log = message => console.error(message), output = line => process.stdout.write(`${line}\n`) }) {
|
|
190
|
+
this.binary = binary;
|
|
191
|
+
this.destinations = destinations;
|
|
192
|
+
this.env = env;
|
|
193
|
+
this.spawn = spawn;
|
|
194
|
+
this.log = log;
|
|
195
|
+
this.output = output;
|
|
196
|
+
this.children = [];
|
|
197
|
+
this.started = false;
|
|
198
|
+
this.namespace = new Map();
|
|
199
|
+
this.initialized = false;
|
|
200
|
+
this.clientInitialized = false;
|
|
201
|
+
this.pendingNotifications = [];
|
|
202
|
+
this.shuttingDown = false;
|
|
203
|
+
this.usedSlugs = new Map();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
start() {
|
|
207
|
+
if (this.started) return this;
|
|
208
|
+
this.started = true;
|
|
209
|
+
for (const destination of this.destinations) {
|
|
210
|
+
try {
|
|
211
|
+
this.log(`[${destination.name}] starting VSP child (client=${destination.client || '001'})`);
|
|
212
|
+
this.children.push({
|
|
213
|
+
destination,
|
|
214
|
+
child: new Child(this.binary, destination, {
|
|
215
|
+
env: this.env,
|
|
216
|
+
spawn: this.spawn,
|
|
217
|
+
log: this.log,
|
|
218
|
+
onNotification: notification => {
|
|
219
|
+
if (this.clientInitialized) this.output(JSON.stringify(notification));
|
|
220
|
+
else this.pendingNotifications.push(notification);
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
});
|
|
224
|
+
} catch (error) {
|
|
225
|
+
this.log(`[${destination.name}] failed to start child: ${diagnosticText(error.message)}`);
|
|
226
|
+
void closeDestinationRoute(destination);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (!this.children.length) {
|
|
230
|
+
this.started = false;
|
|
231
|
+
for (const destination of this.destinations) void closeDestinationRoute(destination);
|
|
232
|
+
throw new Error('No destination child could be started');
|
|
233
|
+
}
|
|
234
|
+
return this;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async initializeChildren(params) {
|
|
238
|
+
const healthy = [];
|
|
239
|
+
for (const entry of this.children) {
|
|
240
|
+
this.log(`[${entry.destination.name}] initializing VSP MCP session`);
|
|
241
|
+
try {
|
|
242
|
+
entry.server = await entry.child.initialize(params);
|
|
243
|
+
this.log(`[${entry.destination.name}] VSP MCP session initialized`);
|
|
244
|
+
healthy.push(entry);
|
|
245
|
+
} catch (error) {
|
|
246
|
+
this.log(`[${entry.destination.name}] initialization failed: ${diagnosticText(error.message)}`);
|
|
247
|
+
await entry.child.close();
|
|
248
|
+
await closeDestinationRoute(entry.destination);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
this.children = healthy;
|
|
252
|
+
if (!healthy.length) throw new Error('No destination child initialized successfully');
|
|
253
|
+
this.initialized = true;
|
|
254
|
+
return healthy;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async mergedTools() {
|
|
258
|
+
this.namespace.clear();
|
|
259
|
+
this.usedSlugs.clear();
|
|
260
|
+
const merged = [];
|
|
261
|
+
for (const entry of this.children) {
|
|
262
|
+
const slug = slugifyDestination(entry.destination.name, this.usedSlugs);
|
|
263
|
+
const lintName = `${slug}__${ABAP_LINT_TOOL.name}`;
|
|
264
|
+
this.namespace.set(lintName, { handler: runABAPLint });
|
|
265
|
+
merged.push({ ...ABAP_LINT_TOOL, name: lintName });
|
|
266
|
+
try {
|
|
267
|
+
for (const tool of await entry.child.listTools()) {
|
|
268
|
+
if (tool.name === 'SAP') {
|
|
269
|
+
const applicationLogName = `${slug}__GetApplicationLog`;
|
|
270
|
+
this.namespace.set(applicationLogName, {
|
|
271
|
+
entry,
|
|
272
|
+
upstream: 'SAP',
|
|
273
|
+
publicName: 'GetApplicationLog',
|
|
274
|
+
transformArguments: applicationLogArguments
|
|
275
|
+
});
|
|
276
|
+
merged.push({
|
|
277
|
+
name: applicationLogName,
|
|
278
|
+
description: `${APPLICATION_LOG_DESCRIPTION} [destination: ${entry.destination.name}]`,
|
|
279
|
+
inputSchema: APPLICATION_LOG_SCHEMA
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const name = `${slug}__${tool.name}`;
|
|
284
|
+
this.namespace.set(name, { entry, upstream: tool.name });
|
|
285
|
+
merged.push({ ...tool, name, description: `${tool.description || tool.name} [destination: ${entry.destination.name}]` });
|
|
286
|
+
}
|
|
287
|
+
} catch (error) {
|
|
288
|
+
this.log(`[${entry.destination.name}] tools/list failed: ${redactText(error.message)}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (!merged.length && this.children.length) throw new Error('No destination child provided tools');
|
|
292
|
+
return merged;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async handle(message) {
|
|
296
|
+
if (message.method?.startsWith('notifications/')) {
|
|
297
|
+
for (const entry of this.children) entry.child.notify(message.method, message.params);
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
if (message.id === undefined) return null;
|
|
301
|
+
try {
|
|
302
|
+
if (message.method === 'initialize') {
|
|
303
|
+
await this.initializeChildren(message.params || {});
|
|
304
|
+
return rpcResult(message.id, { protocolVersion: message.params?.protocolVersion || '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: this.env.BAS_VSP_DESTINATION || this.children[0].destination.name, version: '0.1.0' } });
|
|
305
|
+
}
|
|
306
|
+
if (!this.initialized) return rpcError(message.id, -32002, 'MCP proxy is not initialized');
|
|
307
|
+
if (message.method === 'tools/list') return rpcResult(message.id, { tools: await this.mergedTools() });
|
|
308
|
+
if (message.method === 'tools/call') {
|
|
309
|
+
const name = message.params?.name;
|
|
310
|
+
const mapped = this.namespace.get(name);
|
|
311
|
+
if (!mapped) return rpcError(message.id, -32602, `Unknown namespaced tool: ${name}`);
|
|
312
|
+
if (mapped.handler) return rpcResult(message.id, await mapped.handler(message.params?.arguments));
|
|
313
|
+
const toolName = mapped.publicName || mapped.upstream;
|
|
314
|
+
const upstreamParams = { ...message.params, name: mapped.upstream };
|
|
315
|
+
if (mapped.transformArguments) upstreamParams.arguments = mapped.transformArguments(message.params?.arguments);
|
|
316
|
+
const startedAt = Date.now();
|
|
317
|
+
this.log(`[${mapped.entry.destination.name}] tools/call ${toolName} started`);
|
|
318
|
+
try {
|
|
319
|
+
const response = await mapped.entry.child.request('tools/call', upstreamParams);
|
|
320
|
+
if (response.result?.isError) {
|
|
321
|
+
const detail = Array.isArray(response.result.content)
|
|
322
|
+
? response.result.content.filter(item => item.type === 'text').map(item => item.text).join(' ')
|
|
323
|
+
: '';
|
|
324
|
+
this.log(`[${mapped.entry.destination.name}] tools/call ${toolName} returned an error in ${Date.now() - startedAt}ms${detail ? `: ${diagnosticText(detail)}` : ''}`);
|
|
325
|
+
} else {
|
|
326
|
+
this.log(`[${mapped.entry.destination.name}] tools/call ${toolName} completed in ${Date.now() - startedAt}ms`);
|
|
327
|
+
}
|
|
328
|
+
return response.error ? { ...response, id: message.id } : rpcResult(message.id, response.result);
|
|
329
|
+
} catch (error) {
|
|
330
|
+
this.log(`[${mapped.entry.destination.name}] tools/call ${toolName} failed after ${Date.now() - startedAt}ms: ${diagnosticText(error.rpcError?.message || error.message)}`);
|
|
331
|
+
return error.rpcError ? rpcError(message.id, error.rpcError.code || -32001, error.rpcError.message || error.message, error.rpcError.data) : rpcError(message.id, -32001, `Destination ${mapped.entry.destination.name} failed: ${error.message}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (!FORWARDED_METHODS.has(message.method)) return rpcError(message.id, -32601, `Method not found: ${message.method}`);
|
|
335
|
+
const responses = [];
|
|
336
|
+
for (const entry of this.children) {
|
|
337
|
+
try { responses.push((await entry.child.request(message.method, message.params)).result); } catch (error) { this.log(`[${entry.destination.name}] ${message.method} failed: ${redactText(error.message)}`); }
|
|
338
|
+
}
|
|
339
|
+
return rpcResult(message.id, responses.length === 1 ? responses[0] : responses);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
this.log(`[MCP] ${message.method || 'request'} failed: ${diagnosticText(error.message)}`);
|
|
342
|
+
return rpcError(message.id, -32000, redactText(error.message));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async serve(input = process.stdin, output = this.output) {
|
|
347
|
+
this.output = output;
|
|
348
|
+
this.start();
|
|
349
|
+
let buffer = '';
|
|
350
|
+
const onData = async chunk => {
|
|
351
|
+
buffer += chunk;
|
|
352
|
+
let newline;
|
|
353
|
+
while ((newline = buffer.indexOf('\n')) >= 0) {
|
|
354
|
+
const line = buffer.slice(0, newline).trim();
|
|
355
|
+
buffer = buffer.slice(newline + 1);
|
|
356
|
+
if (!line) continue;
|
|
357
|
+
try {
|
|
358
|
+
const message = JSON.parse(line);
|
|
359
|
+
const response = await this.handle(message);
|
|
360
|
+
if (response) {
|
|
361
|
+
output(JSON.stringify(response));
|
|
362
|
+
if (message.method === 'initialize') {
|
|
363
|
+
this.clientInitialized = true;
|
|
364
|
+
for (const notification of this.pendingNotifications) output(JSON.stringify(notification));
|
|
365
|
+
this.pendingNotifications.length = 0;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
} catch (error) { this.log(redactText(error.message)); }
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
input.setEncoding('utf8');
|
|
372
|
+
let queue = Promise.resolve();
|
|
373
|
+
input.on('data', chunk => { queue = queue.then(() => onData(chunk)); });
|
|
374
|
+
await once(input, 'end');
|
|
375
|
+
await queue;
|
|
376
|
+
await this.close();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async close() {
|
|
380
|
+
if (this.shuttingDown) return;
|
|
381
|
+
this.shuttingDown = true;
|
|
382
|
+
await Promise.all(this.destinations.map(async destination => {
|
|
383
|
+
const entry = this.children.find(candidate => candidate.destination === destination);
|
|
384
|
+
await entry?.child.close();
|
|
385
|
+
await closeDestinationRoute(destination);
|
|
386
|
+
}));
|
|
387
|
+
}
|
|
388
|
+
}
|
package/src/setup.mjs
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import checkbox from '@inquirer/checkbox';
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
import { stdin, stdout } from 'node:process';
|
|
4
|
+
import { colorText, formatStatus, promptOutput, quietSpinnerTheme } from './terminal-ui.mjs';
|
|
5
|
+
import { discoverDestinations, remediation } from './bas-discovery.mjs';
|
|
6
|
+
import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys } from './cf-destination.mjs';
|
|
7
|
+
import { SAP_DEVELOPMENT_MCP_SERVERS, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, resolveMcpConfigPath } from './mcp-config.mjs';
|
|
8
|
+
|
|
9
|
+
const SETUP_COMMAND = 'sap-ai-dev --setup';
|
|
10
|
+
|
|
11
|
+
function print(output, message) {
|
|
12
|
+
output.write(`${message}\n`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async function confirmCloudFoundryImport({ input = stdin, output = stdout } = {}) {
|
|
17
|
+
const readline = createInterface({ input, output });
|
|
18
|
+
try {
|
|
19
|
+
print(output, formatStatus("Optional import from the current space's Destination service.", 'step', output, 'Cloud Foundry'));
|
|
20
|
+
const answer = await readline.question("Include destinations from the current CF space's Destination service? (y + Enter = import; Enter = skip) ");
|
|
21
|
+
return /^y(?:es)?$/i.test(answer.trim());
|
|
22
|
+
} finally {
|
|
23
|
+
readline.close();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function safeProbe(probe) {
|
|
28
|
+
const result = {};
|
|
29
|
+
if (typeof probe?.status === 'string') result.status = probe.status;
|
|
30
|
+
if (typeof probe?.available === 'boolean') result.available = probe.available;
|
|
31
|
+
if (Number.isFinite(probe?.httpStatus)) result.httpStatus = probe.httpStatus;
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function safeBasDestination(destination) {
|
|
36
|
+
return {
|
|
37
|
+
source: 'bas',
|
|
38
|
+
name: destination.name,
|
|
39
|
+
serverName: destination.name,
|
|
40
|
+
url: destination.url,
|
|
41
|
+
client: destination.client,
|
|
42
|
+
authentication: destination.authentication,
|
|
43
|
+
probe: safeProbe(destination.probe)
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function safeCloudFoundryDestination(destination) {
|
|
48
|
+
const cf = destination.cf || {};
|
|
49
|
+
return {
|
|
50
|
+
source: 'cloud-foundry',
|
|
51
|
+
name: destination.name,
|
|
52
|
+
serverName: destination.serverName,
|
|
53
|
+
client: destination.client,
|
|
54
|
+
authentication: destination.authentication,
|
|
55
|
+
proxyType: destination.proxyType,
|
|
56
|
+
probe: safeProbe(destination.probe),
|
|
57
|
+
cf: {
|
|
58
|
+
spaceGuid: cf.spaceGuid,
|
|
59
|
+
destinationInstanceGuid: cf.destinationInstanceGuid,
|
|
60
|
+
destinationInstanceName: cf.destinationInstanceName,
|
|
61
|
+
destinationKeyName: cf.destinationKeyName,
|
|
62
|
+
...(cf.connectivityInstanceGuid ? {
|
|
63
|
+
connectivityInstanceGuid: cf.connectivityInstanceGuid,
|
|
64
|
+
connectivityInstanceName: cf.connectivityInstanceName,
|
|
65
|
+
connectivityKeyName: cf.connectivityKeyName
|
|
66
|
+
} : {})
|
|
67
|
+
},
|
|
68
|
+
...(destination.disabledReason ? { disabledReason: destination.disabledReason } : {})
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function printConnectionInstructions(output, result) {
|
|
73
|
+
const servers = Object.entries(result?.servers || {});
|
|
74
|
+
if (!servers.length) {
|
|
75
|
+
print(output, '');
|
|
76
|
+
print(output, formatStatus('No destinations were selected; no MCP servers were added.', 'info', output, 'BAS setup'));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
print(output, '');
|
|
80
|
+
print(output, colorText('📡 MCP servers now available:', 'cyan', output));
|
|
81
|
+
for (const [name, entry] of servers) {
|
|
82
|
+
const detail = entry.env?.BAS_VSP_DESTINATION
|
|
83
|
+
? `destination: ${entry.env.BAS_VSP_DESTINATION}`
|
|
84
|
+
: (entry.displayName ? `tool server: ${entry.displayName}` : 'tool server');
|
|
85
|
+
print(output, ` • ${name} — ${detail}`);
|
|
86
|
+
}
|
|
87
|
+
print(output, formatStatus('Open the Command Palette → “MCP: List Servers”, select a generated server, and choose “Start Server”.', 'step', output, 'Next'));
|
|
88
|
+
print(output, formatStatus('Inspect or edit entries with “MCP: Open User Configuration”.', 'info', output, 'Config'));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
export async function runSetup({
|
|
93
|
+
env = process.env,
|
|
94
|
+
input = stdin,
|
|
95
|
+
output = stdout,
|
|
96
|
+
discover = discoverDestinations,
|
|
97
|
+
discoverCf = discoverCloudFoundryDestinations,
|
|
98
|
+
confirmCfImport = confirmCloudFoundryImport,
|
|
99
|
+
install = installMcpConfig,
|
|
100
|
+
includeSapDevelopmentToolsPrompt = false
|
|
101
|
+
} = {}) {
|
|
102
|
+
if (!env.H2O_URL) {
|
|
103
|
+
print(output, formatStatus(`Setup skipped: H2O_URL is not set. Run ${SETUP_COMMAND} in a BAS dev space.`, 'info', output, 'BAS setup'));
|
|
104
|
+
return { skipped: true, reason: 'non-bas' };
|
|
105
|
+
}
|
|
106
|
+
if (!input.isTTY || !output.isTTY) {
|
|
107
|
+
print(output, formatStatus(`Interactive setup needs a terminal. Rerun ${SETUP_COMMAND} from a BAS terminal.`, 'warning', output, 'BAS setup'));
|
|
108
|
+
return { skipped: true, reason: 'non-tty' };
|
|
109
|
+
}
|
|
110
|
+
print(output, '');
|
|
111
|
+
print(output, formatStatus('Contacting BAS to discover destinations; this may take a moment.', 'progress', output, 'Setup'));
|
|
112
|
+
|
|
113
|
+
let basDestinations;
|
|
114
|
+
try {
|
|
115
|
+
basDestinations = await discover({ env: { ...env, BAS_VSP_DESTINATION: '' } });
|
|
116
|
+
} catch (error) {
|
|
117
|
+
throw new Error(`BAS discovery failed: ${error.message}`);
|
|
118
|
+
}
|
|
119
|
+
const destinations = basDestinations.map(safeBasDestination);
|
|
120
|
+
const warnings = [];
|
|
121
|
+
let createdKeys = [];
|
|
122
|
+
let configPath;
|
|
123
|
+
let managedKeys = [];
|
|
124
|
+
try {
|
|
125
|
+
configPath = await resolveMcpConfigPath(env);
|
|
126
|
+
managedKeys = collectManagedCloudFoundryKeyReferences(await readMcpConfig(configPath));
|
|
127
|
+
} catch {
|
|
128
|
+
warnings.push('Existing MCP config could not be read before setup; existing CF service keys will not be reused.');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const target = await getCloudFoundryTarget({ env });
|
|
132
|
+
if (!target.available) {
|
|
133
|
+
warnings.push(target.reason);
|
|
134
|
+
} else {
|
|
135
|
+
let shouldImport = false;
|
|
136
|
+
try {
|
|
137
|
+
shouldImport = await confirmCfImport({ input, output });
|
|
138
|
+
} catch {
|
|
139
|
+
warnings.push('Cloud Foundry import prompt failed; CF destinations were skipped.');
|
|
140
|
+
}
|
|
141
|
+
if (shouldImport) {
|
|
142
|
+
print(output, formatStatus('Reading destinations from Cloud Foundry.', 'progress', output, 'Cloud Foundry'));
|
|
143
|
+
try {
|
|
144
|
+
const result = await discoverCf({ env, input, output, spaceGuid: target.spaceGuid, managedKeys });
|
|
145
|
+
createdKeys = Array.isArray(result?.createdKeys) ? result.createdKeys : [];
|
|
146
|
+
const cfDestinations = Array.isArray(result?.destinations) ? result.destinations : [];
|
|
147
|
+
destinations.push(...cfDestinations.map(safeCloudFoundryDestination));
|
|
148
|
+
warnings.push(...(Array.isArray(result?.warnings) ? result.warnings.filter(value => typeof value === 'string') : []));
|
|
149
|
+
} catch {
|
|
150
|
+
warnings.push('Cloud Foundry import failed; BAS setup can continue.');
|
|
151
|
+
}
|
|
152
|
+
} else if (!warnings.some(message => message.includes('Cloud Foundry import prompt failed'))) {
|
|
153
|
+
warnings.push('Cloud Foundry destinations were skipped because import was not confirmed.');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const reportWarnings = async messages => {
|
|
158
|
+
for (const message of messages) print(output, formatStatus(message, 'warning', output, 'Warning'));
|
|
159
|
+
};
|
|
160
|
+
const cleanupKeys = async (keys, config) => {
|
|
161
|
+
const referenced = collectCloudFoundryKeyReferencesFromAllEntries(config);
|
|
162
|
+
const referenceIds = new Set(referenced.map(reference => `${reference.kind}\0${reference.spaceGuid}\0${reference.instanceGuid}\0${reference.keyName}`));
|
|
163
|
+
const candidates = keys.filter(key => !referenceIds.has(`${key.kind}\0${key.spaceGuid}\0${key.instanceGuid}\0${key.keyName}`));
|
|
164
|
+
if (!candidates.length) return [];
|
|
165
|
+
const result = await deleteManagedCloudFoundryServiceKeys({ env, keys: candidates });
|
|
166
|
+
return result.warnings || [];
|
|
167
|
+
};
|
|
168
|
+
const cleanupNewKeysWithoutConfigChange = async () => {
|
|
169
|
+
if (!createdKeys.length) return [];
|
|
170
|
+
const result = await deleteManagedCloudFoundryServiceKeys({ env, keys: createdKeys });
|
|
171
|
+
return result.warnings || [];
|
|
172
|
+
};
|
|
173
|
+
const selectable = destinations.filter(destination => !destination.disabledReason);
|
|
174
|
+
if (!selectable.length) {
|
|
175
|
+
print(output, formatStatus('No selectable BAS or Cloud Foundry destinations were found.', 'warning', output, 'Setup'));
|
|
176
|
+
print(output, remediation);
|
|
177
|
+
await reportWarnings(warnings);
|
|
178
|
+
const cleanupWarnings = await cleanupNewKeysWithoutConfigChange();
|
|
179
|
+
await reportWarnings(cleanupWarnings);
|
|
180
|
+
return { skipped: true, reason: 'no-destinations', warnings: [...warnings, ...cleanupWarnings] };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const orderedDestinations = [...destinations].sort((left, right) => {
|
|
184
|
+
const leftRank = left.disabledReason ? 2 : (left.probe?.available === false ? 1 : 0);
|
|
185
|
+
const rightRank = right.disabledReason ? 2 : (right.probe?.available === false ? 1 : 0);
|
|
186
|
+
return leftRank - rightRank || String(left.name).localeCompare(String(right.name));
|
|
187
|
+
});
|
|
188
|
+
const choices = orderedDestinations.map(destination => {
|
|
189
|
+
const probe = destination.probe?.status || 'unknown';
|
|
190
|
+
const source = destination.source === 'cloud-foundry'
|
|
191
|
+
? `CF ${destination.cf.destinationInstanceName}`
|
|
192
|
+
: 'BAS';
|
|
193
|
+
const disabled = destination.disabledReason;
|
|
194
|
+
const state = disabled ? 'disabled' : (destination.probe?.available === false ? `fail:${probe}` : `ok:${probe}`);
|
|
195
|
+
return {
|
|
196
|
+
value: destination,
|
|
197
|
+
name: `${destination.name} (${source}, client ${destination.client}, ${state})`,
|
|
198
|
+
...(disabled ? { disabled } : {}),
|
|
199
|
+
checked: false
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
print(output, '');
|
|
203
|
+
print(output, formatStatus('Choose the destinations to add. Reachable destinations are shown first.', 'step', output, 'Setup'));
|
|
204
|
+
print(output, '');
|
|
205
|
+
print(output, ' Space = select/deselect · a = toggle all · Enter = confirm.');
|
|
206
|
+
print(output, ' Nothing selected removes this add-on’s MCP entries.');
|
|
207
|
+
print(output, '');
|
|
208
|
+
const selected = await checkbox({
|
|
209
|
+
message: colorText('🧭 Select destinations', 'cyan', output),
|
|
210
|
+
choices,
|
|
211
|
+
required: false,
|
|
212
|
+
shortcuts: { all: 'a', invert: null },
|
|
213
|
+
theme: quietSpinnerTheme
|
|
214
|
+
}, { input, output: promptOutput(output) });
|
|
215
|
+
|
|
216
|
+
let sapDevelopmentServers = [];
|
|
217
|
+
if (includeSapDevelopmentToolsPrompt) {
|
|
218
|
+
print(output, '');
|
|
219
|
+
print(output, formatStatus('Optionally add companion MCP servers for full-stack SAP development.', 'step', output, 'Tools'));
|
|
220
|
+
print(output, ' Recommended for RAP/Fiori/CAP/UI validation work. Leave empty to only configure ABAP/ADT destination servers.');
|
|
221
|
+
print(output, '');
|
|
222
|
+
sapDevelopmentServers = await checkbox({
|
|
223
|
+
message: colorText('🧰 Select companion tools', 'cyan', output),
|
|
224
|
+
choices: SAP_DEVELOPMENT_MCP_SERVERS.map(server => ({
|
|
225
|
+
value: server.id,
|
|
226
|
+
name: `${server.name} — ${server.description}`,
|
|
227
|
+
checked: false
|
|
228
|
+
})),
|
|
229
|
+
required: false,
|
|
230
|
+
shortcuts: { all: 'a', invert: null },
|
|
231
|
+
theme: quietSpinnerTheme
|
|
232
|
+
}, { input, output: promptOutput(output) });
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
const result = await install(selected, { env, sapDevelopmentServers });
|
|
236
|
+
const location = result?.path ? ` in ${result.path}` : '';
|
|
237
|
+
print(output, '');
|
|
238
|
+
const configuredCount = selected.length + sapDevelopmentServers.length;
|
|
239
|
+
print(output, formatStatus(`Configured ${configuredCount} MCP server${configuredCount === 1 ? '' : 's'}${location}.`, 'success', output, 'BAS setup'));
|
|
240
|
+
printConnectionInstructions(output, result);
|
|
241
|
+
let cleanupWarnings = [];
|
|
242
|
+
const finalPath = result?.path || configPath;
|
|
243
|
+
if (!finalPath) {
|
|
244
|
+
cleanupWarnings.push('CF service-key cleanup skipped because the final MCP config path is unavailable.');
|
|
245
|
+
} else {
|
|
246
|
+
try {
|
|
247
|
+
const finalConfig = await readMcpConfig(finalPath);
|
|
248
|
+
cleanupWarnings = await cleanupKeys([...managedKeys, ...createdKeys], finalConfig);
|
|
249
|
+
} catch {
|
|
250
|
+
cleanupWarnings.push('CF service-key cleanup skipped because the final MCP config could not be read.');
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
await reportWarnings(warnings);
|
|
254
|
+
await reportWarnings(cleanupWarnings);
|
|
255
|
+
return { selected, ...(result || {}), warnings: [...warnings, ...cleanupWarnings] };
|
|
256
|
+
} catch (error) {
|
|
257
|
+
const cleanupWarnings = [];
|
|
258
|
+
if (createdKeys.length) {
|
|
259
|
+
const finalPath = configPath;
|
|
260
|
+
if (!finalPath) {
|
|
261
|
+
cleanupWarnings.push('CF service-key cleanup skipped because the MCP config path is unavailable.');
|
|
262
|
+
} else {
|
|
263
|
+
try {
|
|
264
|
+
const actualConfig = await readMcpConfig(finalPath);
|
|
265
|
+
cleanupWarnings.push(...await cleanupKeys(createdKeys, actualConfig));
|
|
266
|
+
} catch {
|
|
267
|
+
cleanupWarnings.push('CF service-key cleanup skipped because the MCP config could not be read.');
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
await reportWarnings([...warnings, ...cleanupWarnings]);
|
|
272
|
+
throw new Error(`MCP config writing failed: ${error.message}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|