sap-ai-dev-toolkit 0.2.7 → 0.3.1
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 +4 -3
- package/.github/agents/abap-runtime-debugger.agent.md +1 -1
- package/.github/agents/rap-service-developer.agent.md +1 -1
- package/.github/skills/abap-development/SKILL.md +2 -1
- package/.github/skills/rap-service-delivery/SKILL.md +4 -1
- package/.github/skills/sap-transport-release/SKILL.md +2 -2
- package/README.md +82 -34
- package/package.json +1 -1
- package/scripts/ensure-go.mjs +5 -4
- package/scripts/install-user-copilot-assets.mjs +6 -1
- package/scripts/postinstall.mjs +11 -11
- package/src/bas-discovery.mjs +3 -2
- package/src/binary.mjs +27 -17
- package/src/branding.mjs +21 -0
- package/src/cf-destination.mjs +1 -1
- package/src/demo-vsp.mjs +113 -0
- package/src/engineering-tools.mjs +641 -0
- package/src/launcher.mjs +118 -11
- package/src/mcp-config.mjs +19 -10
- package/src/mcp-proxy.mjs +18 -4
- package/src/setup.mjs +5 -6
- package/test/cf-destination.test.mjs +2 -2
- package/test/cf-runtime.test.mjs +12 -12
- package/test/copilot-assets.test.mjs +26 -0
- package/test/discovery.test.mjs +10 -1
- package/test/launcher.test.mjs +46 -9
- package/test/mcp-config-cf.test.mjs +49 -5
- package/test/mcp-proxy.test.mjs +1 -1
- package/test/pty.mjs +8 -0
- package/test/setup-cf.test.mjs +4 -4
- package/test/setup.test.mjs +51 -14
- package/tools.md +12 -1
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { ProxyAgent, fetch as undiciFetch } from 'undici';
|
|
3
|
+
import { sanitizeChildEnv } from './bas-discovery.mjs';
|
|
4
|
+
|
|
5
|
+
const CHANGESET_WRITE_TOOLS = new Set(['WriteSource']);
|
|
6
|
+
const TRANSPORT_CHECK_TOOLS = new Set(['GetTransport', 'GetTransportInfo', 'ListDependencies', 'GetInactiveObjects', 'RunUnitTests', 'RunATCCheck']);
|
|
7
|
+
const MAX_CHANGE_BYTES = 128 * 1024;
|
|
8
|
+
const MAX_CHANGESET_SIZE = 12;
|
|
9
|
+
const MAX_REMOTE_BODY_BYTES = 2 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
function object(value) {
|
|
12
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function toolError(message) {
|
|
16
|
+
return { isError: true, content: [{ type: 'text', text: message }] };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function toolJson(value) {
|
|
20
|
+
return { isError: false, content: [{ type: 'text', text: JSON.stringify(value) }], structuredContent: value };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function safeDiagnostic(value) {
|
|
24
|
+
return String(value?.message || value || 'unknown error')
|
|
25
|
+
.replace(/(authorization|proxy-authorization)\s*[:=]\s*(?:bearer\s+|basic\s+)?[^\s,;]+/gi, '$1=[redacted]')
|
|
26
|
+
.replace(/(cookie|password|secret|token)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]')
|
|
27
|
+
.replace(/\s+/g, ' ')
|
|
28
|
+
.slice(0, 300);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function textTool(name, description, properties, required, annotations = { readOnlyHint: true }) {
|
|
32
|
+
return {
|
|
33
|
+
name,
|
|
34
|
+
description,
|
|
35
|
+
inputSchema: { type: 'object', properties, required, additionalProperties: false },
|
|
36
|
+
annotations
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const changeSchema = {
|
|
41
|
+
type: 'object',
|
|
42
|
+
properties: {
|
|
43
|
+
object_type: { type: 'string', minLength: 1 },
|
|
44
|
+
object_name: { type: 'string', minLength: 1 },
|
|
45
|
+
source_arguments: { type: 'object', description: 'Exact arguments for the destination GetSource tool.' },
|
|
46
|
+
current_source: { type: 'string', description: 'Full source read from SAP before this proposal.' },
|
|
47
|
+
replacement_source: { type: 'string', description: 'Full replacement source to stage.' },
|
|
48
|
+
write_tool: { type: 'string', enum: [...CHANGESET_WRITE_TOOLS] },
|
|
49
|
+
write_arguments: { type: 'object', description: 'Exact arguments for WriteSource, including the full replacement source and matching object_type/name.' }
|
|
50
|
+
},
|
|
51
|
+
required: ['object_type', 'object_name', 'source_arguments', 'current_source', 'replacement_source', 'write_tool', 'write_arguments'],
|
|
52
|
+
additionalProperties: false
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const TOOLS = {
|
|
56
|
+
PrepareABAPChangeSet: textTool(
|
|
57
|
+
'PrepareABAPChangeSet',
|
|
58
|
+
'Stage a reviewable multi-object ABAP change set. This only reads current sources and stores the proposal in memory; it does not edit SAP.',
|
|
59
|
+
{
|
|
60
|
+
title: { type: 'string', minLength: 1, maxLength: 120 },
|
|
61
|
+
changes: { type: 'array', minItems: 1, maxItems: MAX_CHANGESET_SIZE, items: changeSchema }
|
|
62
|
+
},
|
|
63
|
+
['title', 'changes']
|
|
64
|
+
),
|
|
65
|
+
ApplyABAPChangeSet: textTool(
|
|
66
|
+
'ApplyABAPChangeSet',
|
|
67
|
+
'Apply a staged ABAP change set after re-reading every target and confirming its source still matches the reviewed version. Call only after the user approves the proposal.',
|
|
68
|
+
{ change_set_id: { type: 'string', minLength: 1 } },
|
|
69
|
+
['change_set_id'],
|
|
70
|
+
{ readOnlyHint: false, destructiveHint: true, idempotentHint: false }
|
|
71
|
+
),
|
|
72
|
+
CheckTransportReadiness: textTool(
|
|
73
|
+
'CheckTransportReadiness',
|
|
74
|
+
'Collect a transport request and dependency, inactive-object, ABAP Unit, and ATC evidence bundle. Only registered read/check tools are accepted; inspect findings before handoff.',
|
|
75
|
+
{
|
|
76
|
+
transport_id: { type: 'string', minLength: 1 },
|
|
77
|
+
checks: {
|
|
78
|
+
type: 'array', minItems: 1, maxItems: 12,
|
|
79
|
+
items: {
|
|
80
|
+
type: 'object', properties: {
|
|
81
|
+
tool: { type: 'string', enum: [...TRANSPORT_CHECK_TOOLS] },
|
|
82
|
+
arguments: { type: 'object' }
|
|
83
|
+
}, required: ['tool', 'arguments'], additionalProperties: false
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
['transport_id', 'checks'],
|
|
88
|
+
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false }
|
|
89
|
+
),
|
|
90
|
+
PlanABAPCloudMigration: textTool(
|
|
91
|
+
'PlanABAPCloudMigration',
|
|
92
|
+
'Check SAP release-state evidence for a bounded set of object URIs and prioritize objects that need migration review. It reports SAP evidence without inventing replacement APIs.',
|
|
93
|
+
{
|
|
94
|
+
objects: {
|
|
95
|
+
type: 'array', minItems: 1, maxItems: 50,
|
|
96
|
+
items: {
|
|
97
|
+
type: 'object', properties: {
|
|
98
|
+
name: { type: 'string', minLength: 1 },
|
|
99
|
+
object_uri: { type: 'string', pattern: '^/sap/bc/adt/' }
|
|
100
|
+
}, required: ['name', 'object_uri'], additionalProperties: false
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
['objects']
|
|
105
|
+
),
|
|
106
|
+
GenerateRAPRegressionSuite: textTool(
|
|
107
|
+
'GenerateRAPRegressionSuite',
|
|
108
|
+
'Read an OData service metadata document and return a reusable, read-only smoke suite for its entity sets. Only the connected destination is contacted.',
|
|
109
|
+
{ service_root: { type: 'string', minLength: 1, maxLength: 1024 } },
|
|
110
|
+
['service_root']
|
|
111
|
+
),
|
|
112
|
+
RunRAPRegressionSuite: textTool(
|
|
113
|
+
'RunRAPRegressionSuite',
|
|
114
|
+
'Run a saved RAP OData GET-only suite against this destination. Requests stay under the supplied OData service root; request bodies and custom authorization headers are not supported.',
|
|
115
|
+
{
|
|
116
|
+
suite: {
|
|
117
|
+
type: 'object', properties: {
|
|
118
|
+
name: { type: 'string', minLength: 1, maxLength: 120 },
|
|
119
|
+
service_root: { type: 'string', minLength: 1, maxLength: 1024 },
|
|
120
|
+
cases: {
|
|
121
|
+
type: 'array', minItems: 1, maxItems: 30,
|
|
122
|
+
items: {
|
|
123
|
+
type: 'object', properties: {
|
|
124
|
+
name: { type: 'string', minLength: 1, maxLength: 120 },
|
|
125
|
+
path: { type: 'string', minLength: 1, maxLength: 1024 },
|
|
126
|
+
expected_status: { type: 'integer', minimum: 100, maximum: 599 },
|
|
127
|
+
expected_content_type: { type: 'string', enum: ['json', 'xml'] },
|
|
128
|
+
assertions: {
|
|
129
|
+
type: 'array', maxItems: 20,
|
|
130
|
+
items: {
|
|
131
|
+
type: 'object', properties: {
|
|
132
|
+
json_path: { type: 'string', minLength: 1, maxLength: 256 },
|
|
133
|
+
operator: { type: 'string', enum: ['exists', 'equals', 'array'] },
|
|
134
|
+
expected: {}
|
|
135
|
+
}, required: ['json_path', 'operator'], additionalProperties: false
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}, required: ['name', 'path', 'expected_status'], additionalProperties: false
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}, required: ['name', 'service_root', 'cases'], additionalProperties: false
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
['suite']
|
|
145
|
+
)
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
function validateObject(value, label, allowed) {
|
|
149
|
+
if (!object(value)) throw new Error(`${label} must be an object`);
|
|
150
|
+
if (allowed) {
|
|
151
|
+
const unexpected = Object.keys(value).find(key => !allowed.includes(key));
|
|
152
|
+
if (unexpected) throw new Error(`${label} contains unsupported field ${unexpected}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function lineDiff(before, after) {
|
|
157
|
+
const left = before.split(/\r?\n/);
|
|
158
|
+
const right = after.split(/\r?\n/);
|
|
159
|
+
if (left.length > 1000 || right.length > 1000) return 'Diff omitted: each source is limited to 1,000 lines for inline preview.';
|
|
160
|
+
const rows = Array.from({ length: left.length + 1 }, () => new Uint16Array(right.length + 1));
|
|
161
|
+
for (let i = left.length - 1; i >= 0; i -= 1) {
|
|
162
|
+
for (let j = right.length - 1; j >= 0; j -= 1) {
|
|
163
|
+
rows[i][j] = left[i] === right[j]
|
|
164
|
+
? rows[i + 1][j + 1] + 1
|
|
165
|
+
: Math.max(rows[i + 1][j], rows[i][j + 1]);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const output = [];
|
|
169
|
+
let i = 0;
|
|
170
|
+
let j = 0;
|
|
171
|
+
while (i < left.length || j < right.length) {
|
|
172
|
+
if (i < left.length && j < right.length && left[i] === right[j]) {
|
|
173
|
+
output.push(` ${left[i]}`);
|
|
174
|
+
i += 1;
|
|
175
|
+
j += 1;
|
|
176
|
+
} else if (i < left.length && (j === right.length || rows[i + 1][j] >= rows[i][j + 1])) {
|
|
177
|
+
output.push(`-${left[i++]}`);
|
|
178
|
+
} else {
|
|
179
|
+
output.push(`+${right[j++]}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return output.join('\n');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function sourceFromResult(result) {
|
|
186
|
+
const candidates = [result?.structuredContent, result?.result, result];
|
|
187
|
+
for (const candidate of candidates) {
|
|
188
|
+
if (object(candidate)) {
|
|
189
|
+
for (const key of ['source', 'sourceCode', 'source_code']) {
|
|
190
|
+
if (typeof candidate[key] === 'string') return candidate[key];
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const content = Array.isArray(result?.content) ? result.content.filter(item => item?.type === 'text').map(item => item.text) : [];
|
|
195
|
+
if (content.length !== 1) return undefined;
|
|
196
|
+
const text = content[0];
|
|
197
|
+
try {
|
|
198
|
+
const parsed = JSON.parse(text);
|
|
199
|
+
if (object(parsed)) {
|
|
200
|
+
for (const key of ['source', 'sourceCode', 'source_code']) if (typeof parsed[key] === 'string') return parsed[key];
|
|
201
|
+
}
|
|
202
|
+
} catch {}
|
|
203
|
+
return text;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function resultData(result) {
|
|
207
|
+
if (Array.isArray(result?.content)) {
|
|
208
|
+
return result.content.filter(item => item?.type === 'text').map(item => item.text).join('\n');
|
|
209
|
+
}
|
|
210
|
+
return result?.structuredContent ?? result ?? null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function releaseClassification(result) {
|
|
214
|
+
const candidates = [result?.structuredContent, result];
|
|
215
|
+
for (const candidate of candidates) {
|
|
216
|
+
if (!object(candidate)) continue;
|
|
217
|
+
for (const [key, value] of Object.entries(candidate)) {
|
|
218
|
+
const field = key.toLowerCase().replace(/[^a-z]/g, '');
|
|
219
|
+
if (field === 'isreleased' || field === 'released') {
|
|
220
|
+
if (value === true) return 'released';
|
|
221
|
+
if (value === false) return 'not-released';
|
|
222
|
+
}
|
|
223
|
+
if (!['releasestate', 'apireleasestate', 'status', 'state'].includes(field) || typeof value !== 'string') continue;
|
|
224
|
+
const state = value.toLowerCase().replace(/[_-]+/g, ' ').trim();
|
|
225
|
+
if (['released', 'released for cloud', 'released for cloud development', 'released for use in cloud development'].includes(state)) return 'released';
|
|
226
|
+
if (['not released', 'unreleased', 'not released for cloud', 'not released for cloud development'].includes(state)) return 'not-released';
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const text = resultData(result);
|
|
230
|
+
if (typeof text === 'string') {
|
|
231
|
+
try { return releaseClassification({ structuredContent: JSON.parse(text) }); } catch {}
|
|
232
|
+
}
|
|
233
|
+
return 'unknown';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function hashSource(source) {
|
|
237
|
+
return createHash('sha256').update(source).digest('hex');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function containsSource(value, source) {
|
|
241
|
+
if (typeof value === 'string') return value === source;
|
|
242
|
+
if (Array.isArray(value)) return value.some(entry => containsSource(entry, source));
|
|
243
|
+
if (object(value)) return Object.values(value).some(entry => containsSource(entry, source));
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function validateChangeSet(args, writableTools) {
|
|
248
|
+
validateObject(args, 'arguments', ['title', 'changes']);
|
|
249
|
+
if (typeof args.title !== 'string' || !args.title.trim() || args.title.length > 120) throw new Error('title must be 1–120 characters');
|
|
250
|
+
if (!Array.isArray(args.changes) || args.changes.length < 1 || args.changes.length > MAX_CHANGESET_SIZE) throw new Error(`changes must contain 1–${MAX_CHANGESET_SIZE} objects`);
|
|
251
|
+
const seen = new Set();
|
|
252
|
+
for (const [index, change] of args.changes.entries()) {
|
|
253
|
+
validateObject(change, `changes[${index}]`, ['object_type', 'object_name', 'source_arguments', 'current_source', 'replacement_source', 'write_tool', 'write_arguments']);
|
|
254
|
+
for (const key of ['object_type', 'object_name', 'current_source', 'replacement_source', 'write_tool']) {
|
|
255
|
+
if (typeof change[key] !== 'string' || !change[key].length) throw new Error(`changes[${index}].${key} must be a nonempty string`);
|
|
256
|
+
}
|
|
257
|
+
validateObject(change.source_arguments, `changes[${index}].source_arguments`);
|
|
258
|
+
validateObject(change.write_arguments, `changes[${index}].write_arguments`);
|
|
259
|
+
if (!writableTools.has(change.write_tool) || !CHANGESET_WRITE_TOOLS.has(change.write_tool)) throw new Error(`changes[${index}].write_tool is unavailable or not permitted`);
|
|
260
|
+
if (String(change.source_arguments.object_type || '').toUpperCase() !== change.object_type.toUpperCase() ||
|
|
261
|
+
String(change.source_arguments.name || '').toUpperCase() !== change.object_name.toUpperCase()) {
|
|
262
|
+
throw new Error(`changes[${index}].source_arguments must identify the same object`);
|
|
263
|
+
}
|
|
264
|
+
if (String(change.write_arguments.object_type || '').toUpperCase() !== change.object_type.toUpperCase() ||
|
|
265
|
+
String(change.write_arguments.name || '').toUpperCase() !== change.object_name.toUpperCase()) {
|
|
266
|
+
throw new Error(`changes[${index}].write_arguments must identify the same object`);
|
|
267
|
+
}
|
|
268
|
+
if (!containsSource(change.write_arguments, change.replacement_source)) throw new Error(`changes[${index}].write_arguments must contain the exact replacement_source`);
|
|
269
|
+
for (const key of ['current_source', 'replacement_source']) {
|
|
270
|
+
if (Buffer.byteLength(change[key], 'utf8') > MAX_CHANGE_BYTES) throw new Error(`changes[${index}].${key} exceeds ${MAX_CHANGE_BYTES} bytes`);
|
|
271
|
+
if (change[key].split(/\r?\n/).length > 1000) throw new Error(`changes[${index}].${key} exceeds the 1,000-line review limit`);
|
|
272
|
+
}
|
|
273
|
+
if (change.current_source === change.replacement_source) throw new Error(`changes[${index}] does not change the source`);
|
|
274
|
+
const identity = `${change.object_type.toUpperCase()}\0${change.object_name.toUpperCase()}`;
|
|
275
|
+
if (seen.has(identity)) throw new Error(`Change set contains duplicate object ${change.object_type} ${change.object_name}`);
|
|
276
|
+
seen.add(identity);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function sanitizeServiceRoot(value, destinationUrl) {
|
|
281
|
+
if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//') || value.includes('\\')) {
|
|
282
|
+
throw new Error('service_root must be an absolute path on the connected SAP destination');
|
|
283
|
+
}
|
|
284
|
+
const base = new URL(destinationUrl);
|
|
285
|
+
const service = new URL(value, base);
|
|
286
|
+
if (service.origin !== base.origin || service.search || service.hash || !service.pathname.toLowerCase().startsWith('/sap/opu/odata')) {
|
|
287
|
+
throw new Error('service_root must be a query-free /sap/opu/odata path on the connected destination');
|
|
288
|
+
}
|
|
289
|
+
const segments = service.pathname.split('/');
|
|
290
|
+
let rawSegments;
|
|
291
|
+
try { rawSegments = decodeURIComponent(value.split(/[?#]/, 1)[0]).split('/'); }
|
|
292
|
+
catch { throw new Error('service_root path encoding is invalid'); }
|
|
293
|
+
if (segments.includes('..') || segments.includes('.') || rawSegments.includes('..') || rawSegments.includes('.')) throw new Error('service_root must not contain dot segments');
|
|
294
|
+
service.pathname = `${service.pathname.replace(/\/+$/, '')}/`;
|
|
295
|
+
return service.pathname;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function parseEntitySets(xml) {
|
|
299
|
+
const names = new Set();
|
|
300
|
+
const expression = /<(?:[A-Za-z_][\w.-]*:)?EntitySet\b([^>]*)>/gi;
|
|
301
|
+
let match;
|
|
302
|
+
while ((match = expression.exec(xml))) {
|
|
303
|
+
const name = /\bName\s*=\s*(["'])(.*?)\1/i.exec(match[1])?.[2];
|
|
304
|
+
if (name && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) names.add(name);
|
|
305
|
+
}
|
|
306
|
+
return [...names].sort((a, b) => a.localeCompare(b));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function readBody(response) {
|
|
310
|
+
if (!response.body) return '';
|
|
311
|
+
const reader = response.body.getReader();
|
|
312
|
+
const chunks = [];
|
|
313
|
+
let size = 0;
|
|
314
|
+
try {
|
|
315
|
+
while (true) {
|
|
316
|
+
const { done, value } = await reader.read();
|
|
317
|
+
if (done) break;
|
|
318
|
+
size += value.byteLength;
|
|
319
|
+
if (size > MAX_REMOTE_BODY_BYTES) {
|
|
320
|
+
await reader.cancel();
|
|
321
|
+
throw new Error('SAP response exceeded the 2 MiB read limit');
|
|
322
|
+
}
|
|
323
|
+
chunks.push(Buffer.from(value));
|
|
324
|
+
}
|
|
325
|
+
} finally {
|
|
326
|
+
reader.releaseLock();
|
|
327
|
+
}
|
|
328
|
+
return Buffer.concat(chunks, size).toString('utf8');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function responseForDemo(path) {
|
|
332
|
+
if (path.endsWith('$metadata')) {
|
|
333
|
+
return {
|
|
334
|
+
status: 200,
|
|
335
|
+
contentType: 'application/xml',
|
|
336
|
+
body: '<?xml version="1.0"?><edmx:Edmx><edmx:DataServices><Schema><EntityContainer><EntitySet Name="Orders"/><EntitySet Name="OrderItems"/></EntityContainer></Schema></edmx:DataServices></edmx:Edmx>'
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
return { status: 200, contentType: 'application/json', body: JSON.stringify({ value: [{ ID: '10000001', Description: 'Sample order' }] }) };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function fetchOData(entry, path, accept, env) {
|
|
343
|
+
if (entry.destination.demo) return responseForDemo(path);
|
|
344
|
+
const childEnv = { ...sanitizeChildEnv(env), ...(entry.destination.childEnv || {}) };
|
|
345
|
+
const proxyUrl = childEnv.HTTP_PROXY || childEnv.http_proxy;
|
|
346
|
+
if (!proxyUrl) throw new Error('No HTTP proxy is configured for the destination');
|
|
347
|
+
const target = new URL(path, entry.destination.url);
|
|
348
|
+
const base = new URL(entry.destination.url);
|
|
349
|
+
if (target.origin !== base.origin || target.username || target.password || target.hash) throw new Error('OData request escaped the connected destination');
|
|
350
|
+
const dispatcher = new ProxyAgent(proxyUrl);
|
|
351
|
+
try {
|
|
352
|
+
const response = await undiciFetch(target, {
|
|
353
|
+
method: 'GET',
|
|
354
|
+
headers: { accept },
|
|
355
|
+
dispatcher,
|
|
356
|
+
signal: AbortSignal.timeout(10_000),
|
|
357
|
+
redirect: 'manual'
|
|
358
|
+
});
|
|
359
|
+
const body = await readBody(response);
|
|
360
|
+
return { status: response.status, contentType: response.headers.get('content-type') || '', body };
|
|
361
|
+
} finally {
|
|
362
|
+
await dispatcher.close();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function pathValue(value, path) {
|
|
367
|
+
let current = value;
|
|
368
|
+
for (const segment of path.split('.')) {
|
|
369
|
+
if (!segment || ['__proto__', 'prototype', 'constructor'].includes(segment)) return undefined;
|
|
370
|
+
if (current === null || current === undefined || (typeof current !== 'object' && !Array.isArray(current))) return undefined;
|
|
371
|
+
current = current[segment];
|
|
372
|
+
}
|
|
373
|
+
return current;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function validateSuite(suite, destinationUrl) {
|
|
377
|
+
validateObject(suite, 'suite', ['name', 'service_root', 'cases']);
|
|
378
|
+
if (typeof suite.name !== 'string' || !suite.name.trim() || suite.name.length > 120) throw new Error('suite.name must be 1–120 characters');
|
|
379
|
+
const root = sanitizeServiceRoot(suite.service_root, destinationUrl);
|
|
380
|
+
if (!Array.isArray(suite.cases) || suite.cases.length < 1 || suite.cases.length > 30) throw new Error('suite.cases must contain 1–30 cases');
|
|
381
|
+
const cases = suite.cases.map((testCase, index) => {
|
|
382
|
+
validateObject(testCase, `suite.cases[${index}]`, ['name', 'path', 'expected_status', 'expected_content_type', 'assertions']);
|
|
383
|
+
if (typeof testCase.name !== 'string' || !testCase.name.trim()) throw new Error(`suite.cases[${index}].name is required`);
|
|
384
|
+
if (typeof testCase.path !== 'string' || !testCase.path || testCase.path.startsWith('/') || testCase.path.startsWith('//') || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(testCase.path) || testCase.path.includes('\\') || testCase.path.includes('#')) throw new Error(`suite.cases[${index}].path is invalid`);
|
|
385
|
+
let decodedPath;
|
|
386
|
+
try { decodedPath = decodeURIComponent(testCase.path.split('?', 1)[0]); }
|
|
387
|
+
catch { throw new Error(`suite.cases[${index}].path encoding is invalid`); }
|
|
388
|
+
if (decodedPath.split('/').some(segment => segment === '.' || segment === '..')) throw new Error(`suite.cases[${index}].path must not contain dot segments`);
|
|
389
|
+
if (!Number.isInteger(testCase.expected_status) || testCase.expected_status < 100 || testCase.expected_status > 599) throw new Error(`suite.cases[${index}].expected_status must be an HTTP status`);
|
|
390
|
+
if (testCase.expected_content_type !== undefined && !['json', 'xml'].includes(testCase.expected_content_type)) throw new Error(`suite.cases[${index}].expected_content_type must be json or xml`);
|
|
391
|
+
if (testCase.assertions !== undefined && (!Array.isArray(testCase.assertions) || testCase.assertions.length > 20)) throw new Error(`suite.cases[${index}].assertions must contain at most 20 checks`);
|
|
392
|
+
for (const [assertionIndex, assertion] of (testCase.assertions || []).entries()) {
|
|
393
|
+
validateObject(assertion, `suite.cases[${index}].assertions[${assertionIndex}]`, ['json_path', 'operator', 'expected']);
|
|
394
|
+
if (typeof assertion.json_path !== 'string' || !assertion.json_path || !['exists', 'equals', 'array'].includes(assertion.operator)) throw new Error(`suite.cases[${index}].assertions[${assertionIndex}] is invalid`);
|
|
395
|
+
if (assertion.operator === 'equals' && !Object.hasOwn(assertion, 'expected')) throw new Error(`suite.cases[${index}].assertions[${assertionIndex}].expected is required for equals`);
|
|
396
|
+
}
|
|
397
|
+
const target = new URL(testCase.path, new URL(root, destinationUrl));
|
|
398
|
+
const base = new URL(destinationUrl);
|
|
399
|
+
if (target.origin !== base.origin || !target.pathname.startsWith(root) || target.username || target.password || target.hash) throw new Error(`suite.cases[${index}].path must remain under service_root`);
|
|
400
|
+
return testCase;
|
|
401
|
+
});
|
|
402
|
+
return { name: suite.name, service_root: root, cases };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function remoteTool(entry, name, args, availableTools, log) {
|
|
406
|
+
const definition = availableTools.get(name);
|
|
407
|
+
if (!definition) throw new Error(`Destination does not expose required tool ${name}`);
|
|
408
|
+
const startedAt = Date.now();
|
|
409
|
+
log?.(`[${entry.destination.name}] workflow call ${name} started`);
|
|
410
|
+
try {
|
|
411
|
+
const response = await entry.child.request('tools/call', { name, arguments: args });
|
|
412
|
+
if (response.error) throw new Error(response.error.message || `${name} failed`);
|
|
413
|
+
if (response.result?.isError) throw new Error(resultData(response.result) || `${name} failed`);
|
|
414
|
+
log?.(`[${entry.destination.name}] workflow call ${name} completed in ${Date.now() - startedAt}ms`);
|
|
415
|
+
return response.result;
|
|
416
|
+
} catch (error) {
|
|
417
|
+
log?.(`[${entry.destination.name}] workflow call ${name} failed: ${safeDiagnostic(error)}`);
|
|
418
|
+
throw error;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function createEngineeringTools(entry, upstreamTools, { env = process.env, log } = {}) {
|
|
423
|
+
const available = new Map(upstreamTools.map(tool => [tool.name, tool]));
|
|
424
|
+
entry.engineeringState ||= { proposals: new Map() };
|
|
425
|
+
const { proposals } = entry.engineeringState;
|
|
426
|
+
const definitions = [];
|
|
427
|
+
const handlers = new Map();
|
|
428
|
+
const add = (definition, handler) => {
|
|
429
|
+
if (available.has(definition.name)) return;
|
|
430
|
+
definitions.push(definition);
|
|
431
|
+
handlers.set(definition.name, async args => {
|
|
432
|
+
try {
|
|
433
|
+
return await handler(args);
|
|
434
|
+
} catch (error) {
|
|
435
|
+
return toolError(safeDiagnostic(error));
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
if (available.has('GetSource') && [...available.keys()].some(name => CHANGESET_WRITE_TOOLS.has(name))) {
|
|
441
|
+
add(TOOLS.PrepareABAPChangeSet, async args => {
|
|
442
|
+
validateChangeSet(args, new Set(available.keys()));
|
|
443
|
+
if (proposals.size >= 32) {
|
|
444
|
+
const completed = [...proposals].find(([, proposal]) => proposal.status === 'applied');
|
|
445
|
+
if (completed) proposals.delete(completed[0]);
|
|
446
|
+
else throw new Error('This MCP session already has 32 unapplied change sets. Resolve or restart the session before staging another.');
|
|
447
|
+
}
|
|
448
|
+
const id = randomUUID();
|
|
449
|
+
const changes = args.changes.map(change => ({
|
|
450
|
+
...change,
|
|
451
|
+
applied: false,
|
|
452
|
+
expected_sha256: hashSource(change.current_source)
|
|
453
|
+
}));
|
|
454
|
+
proposals.set(id, { title: args.title.trim(), changes, status: 'staged' });
|
|
455
|
+
return toolJson({
|
|
456
|
+
change_set_id: id,
|
|
457
|
+
title: args.title.trim(),
|
|
458
|
+
status: 'staged',
|
|
459
|
+
destination: entry.destination.name,
|
|
460
|
+
changes: changes.map(change => ({
|
|
461
|
+
object_type: change.object_type,
|
|
462
|
+
object_name: change.object_name,
|
|
463
|
+
expected_sha256: change.expected_sha256,
|
|
464
|
+
diff: lineDiff(change.current_source, change.replacement_source)
|
|
465
|
+
})),
|
|
466
|
+
next_step: 'Review every diff, then call ApplyABAPChangeSet only after approval.'
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
add(TOOLS.ApplyABAPChangeSet, async args => {
|
|
471
|
+
validateObject(args, 'arguments', ['change_set_id']);
|
|
472
|
+
if (typeof args.change_set_id !== 'string') throw new Error('change_set_id is required');
|
|
473
|
+
const proposal = proposals.get(args.change_set_id);
|
|
474
|
+
if (!proposal) throw new Error('Change set was not found in this MCP session');
|
|
475
|
+
if (proposal.status === 'applied') throw new Error('Change set has already been applied');
|
|
476
|
+
if (proposal.status === 'conflict') throw new Error('Source conflict was already detected; prepare and review a new change set.');
|
|
477
|
+
const failures = [];
|
|
478
|
+
for (const change of proposal.changes) {
|
|
479
|
+
if (change.applied) continue;
|
|
480
|
+
try {
|
|
481
|
+
const currentResult = await remoteTool(entry, 'GetSource', change.source_arguments, available, log);
|
|
482
|
+
const current = sourceFromResult(currentResult);
|
|
483
|
+
if (current === undefined) throw new Error(`GetSource did not return source text for ${change.object_type} ${change.object_name}`);
|
|
484
|
+
if (current !== change.current_source || hashSource(current) !== change.expected_sha256) {
|
|
485
|
+
failures.push({ object_type: change.object_type, object_name: change.object_name, status: 'conflict', message: 'Source changed since review; prepare a new change set.' });
|
|
486
|
+
proposal.status = 'conflict';
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
await remoteTool(entry, change.write_tool, change.write_arguments, available, log);
|
|
490
|
+
change.applied = true;
|
|
491
|
+
} catch (error) {
|
|
492
|
+
failures.push({ object_type: change.object_type, object_name: change.object_name, status: 'failed', message: safeDiagnostic(error) });
|
|
493
|
+
proposal.status = proposal.changes.some(item => item.applied) ? 'partially-applied' : 'failed';
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const applied = proposal.changes.filter(change => change.applied).map(change => ({ object_type: change.object_type, object_name: change.object_name, status: 'applied' }));
|
|
498
|
+
if (failures.length) {
|
|
499
|
+
return toolJson({ change_set_id: args.change_set_id, status: proposal.status, applied, failures, remaining: proposal.changes.filter(change => !change.applied).length });
|
|
500
|
+
}
|
|
501
|
+
proposal.status = 'applied';
|
|
502
|
+
return toolJson({ change_set_id: args.change_set_id, status: proposal.status, applied, failures: [] });
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (available.has('GetTransport')) {
|
|
507
|
+
add(TOOLS.CheckTransportReadiness, async args => {
|
|
508
|
+
validateObject(args, 'arguments', ['transport_id', 'checks']);
|
|
509
|
+
if (typeof args.transport_id !== 'string' || !args.transport_id.trim()) throw new Error('transport_id is required');
|
|
510
|
+
if (!Array.isArray(args.checks) || args.checks.length < 1 || args.checks.length > 12) throw new Error('checks must contain 1–12 checks');
|
|
511
|
+
const transportChecks = args.checks.filter(check => check?.tool === 'GetTransport');
|
|
512
|
+
if (transportChecks.length !== 1 || transportChecks[0].arguments?.transport !== args.transport_id) {
|
|
513
|
+
throw new Error('checks must include exactly one GetTransport call for transport_id');
|
|
514
|
+
}
|
|
515
|
+
const results = [];
|
|
516
|
+
for (const [index, check] of args.checks.entries()) {
|
|
517
|
+
validateObject(check, `checks[${index}]`, ['tool', 'arguments']);
|
|
518
|
+
if (!TRANSPORT_CHECK_TOOLS.has(check.tool) || !object(check.arguments)) throw new Error(`checks[${index}] must use a supported read/check tool and object arguments`);
|
|
519
|
+
try {
|
|
520
|
+
const result = await remoteTool(entry, check.tool, check.arguments, available, log);
|
|
521
|
+
results.push({ tool: check.tool, status: 'completed', evidence: resultData(result) });
|
|
522
|
+
} catch (error) {
|
|
523
|
+
results.push({ tool: check.tool, status: 'failed', error: safeDiagnostic(error) });
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const failures = results.filter(result => result.status !== 'completed').length;
|
|
527
|
+
return toolJson({
|
|
528
|
+
transport_id: args.transport_id,
|
|
529
|
+
destination: entry.destination.name,
|
|
530
|
+
status: failures ? 'evidence-incomplete' : 'evidence-collected',
|
|
531
|
+
checks_completed: results.length - failures,
|
|
532
|
+
checks_failed: failures,
|
|
533
|
+
checks: results,
|
|
534
|
+
interpretation: 'Completed calls can still contain findings. Review every evidence item before declaring the transport ready.'
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (available.has('GetAPIReleaseState')) {
|
|
540
|
+
add(TOOLS.PlanABAPCloudMigration, async args => {
|
|
541
|
+
validateObject(args, 'arguments', ['objects']);
|
|
542
|
+
if (!Array.isArray(args.objects) || args.objects.length < 1 || args.objects.length > 50) throw new Error('objects must contain 1–50 entries');
|
|
543
|
+
const results = [];
|
|
544
|
+
for (const [index, item] of args.objects.entries()) {
|
|
545
|
+
validateObject(item, `objects[${index}]`, ['name', 'object_uri']);
|
|
546
|
+
if (typeof item.name !== 'string' || !item.name.trim() || typeof item.object_uri !== 'string' || !/^\/sap\/bc\/adt\//i.test(item.object_uri)) {
|
|
547
|
+
results.push({ name: typeof item.name === 'string' ? item.name : `object-${index + 1}`, status: 'invalid', error: 'Expected an object name and SAP ADT object URI.' });
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
try {
|
|
551
|
+
const result = await remoteTool(entry, 'GetAPIReleaseState', { object_uri: item.object_uri }, available, log);
|
|
552
|
+
results.push({ name: item.name, object_uri: item.object_uri, status: 'checked', release_state: releaseClassification(result), evidence: resultData(result) });
|
|
553
|
+
} catch (error) {
|
|
554
|
+
results.push({ name: item.name, object_uri: item.object_uri, status: 'failed', error: safeDiagnostic(error) });
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return toolJson({
|
|
558
|
+
destination: entry.destination.name,
|
|
559
|
+
assessed: results.length,
|
|
560
|
+
failures: results.filter(result => result.status !== 'checked').length,
|
|
561
|
+
results,
|
|
562
|
+
migration_review_order: results.filter(result => result.status === 'checked').sort((a, b) => {
|
|
563
|
+
const order = { 'not-released': 0, unknown: 1, released: 2 };
|
|
564
|
+
return order[a.release_state] - order[b.release_state];
|
|
565
|
+
}).map(result => ({ name: result.name, release_state: result.release_state })),
|
|
566
|
+
note: 'Release status is SAP evidence for this system. Candidate replacements require separate verification against the target release.'
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
add(TOOLS.GenerateRAPRegressionSuite, async args => {
|
|
572
|
+
validateObject(args, 'arguments', ['service_root']);
|
|
573
|
+
const serviceRoot = sanitizeServiceRoot(args.service_root, entry.destination.url);
|
|
574
|
+
const metadataPath = `${serviceRoot}$metadata`;
|
|
575
|
+
const response = await fetchOData(entry, metadataPath, 'application/xml,text/xml', env);
|
|
576
|
+
if (response.status < 200 || response.status >= 300) throw new Error(`OData metadata request returned HTTP ${response.status}`);
|
|
577
|
+
const entitySets = parseEntitySets(response.body);
|
|
578
|
+
if (!entitySets.length) throw new Error('OData metadata did not contain entity sets');
|
|
579
|
+
return toolJson({
|
|
580
|
+
suite: {
|
|
581
|
+
name: `${entry.destination.name} RAP smoke suite`,
|
|
582
|
+
service_root: serviceRoot,
|
|
583
|
+
cases: [
|
|
584
|
+
{ name: 'OData metadata', path: '$metadata', expected_status: 200, expected_content_type: 'xml' },
|
|
585
|
+
...entitySets.map(name => ({ name: `Read ${name}`, path: `${name}?$top=1`, expected_status: 200, expected_content_type: 'json' }))
|
|
586
|
+
]
|
|
587
|
+
},
|
|
588
|
+
entity_set_count: entitySets.length,
|
|
589
|
+
destination: entry.destination.name
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
add(TOOLS.RunRAPRegressionSuite, async args => {
|
|
594
|
+
validateObject(args, 'arguments', ['suite']);
|
|
595
|
+
const suite = validateSuite(args.suite, entry.destination.url);
|
|
596
|
+
const results = [];
|
|
597
|
+
for (const testCase of suite.cases) {
|
|
598
|
+
const fullPath = new URL(testCase.path, new URL(suite.service_root, entry.destination.url));
|
|
599
|
+
let response;
|
|
600
|
+
try {
|
|
601
|
+
response = await fetchOData(entry, fullPath.pathname + fullPath.search, testCase.expected_content_type === 'xml' ? 'application/xml,text/xml' : 'application/json', env);
|
|
602
|
+
} catch (error) {
|
|
603
|
+
results.push({ name: testCase.name, status: 'failed', error: safeDiagnostic(error) });
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
const checks = [];
|
|
607
|
+
checks.push({ check: 'status', passed: response.status === testCase.expected_status });
|
|
608
|
+
if (testCase.expected_content_type) {
|
|
609
|
+
const expected = testCase.expected_content_type === 'json' ? 'json' : 'xml';
|
|
610
|
+
const actual = response.contentType.toLowerCase();
|
|
611
|
+
checks.push({ check: 'content-type', passed: expected === 'json' ? actual.includes('json') : (actual.includes('xml') || fullPath.pathname.endsWith('$metadata')) });
|
|
612
|
+
}
|
|
613
|
+
if (testCase.assertions?.length) {
|
|
614
|
+
let parsed;
|
|
615
|
+
try { parsed = JSON.parse(response.body); } catch {}
|
|
616
|
+
for (const assertion of testCase.assertions) {
|
|
617
|
+
const actual = parsed === undefined ? undefined : pathValue(parsed, assertion.json_path);
|
|
618
|
+
const passed = assertion.operator === 'exists'
|
|
619
|
+
? actual !== undefined
|
|
620
|
+
: assertion.operator === 'array'
|
|
621
|
+
? Array.isArray(actual)
|
|
622
|
+
: assertion.operator === 'equals' && JSON.stringify(actual) === JSON.stringify(assertion.expected);
|
|
623
|
+
checks.push({ check: `json:${assertion.json_path}:${assertion.operator}`, passed });
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
results.push({
|
|
627
|
+
name: testCase.name,
|
|
628
|
+
status: checks.every(check => check.passed) ? 'passed' : 'failed',
|
|
629
|
+
http_status: response.status,
|
|
630
|
+
checks
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
const failed = results.filter(result => result.status !== 'passed').length;
|
|
634
|
+
return toolJson({ suite: suite.name, destination: entry.destination.name, status: failed ? 'failed' : 'passed', passed: results.length - failed, failed, results });
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
return definitions.map(definition => ({
|
|
638
|
+
definition,
|
|
639
|
+
handler: handlers.get(definition.name)
|
|
640
|
+
}));
|
|
641
|
+
}
|