sap-adt-mcp 0.7.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.
@@ -0,0 +1,86 @@
1
+ import { errorResult, jsonResult, textResult } from "../result.js";
2
+ import { validateSelect, parseDataPreview } from "../data-preview.js";
3
+ import { SYSTEM_HINT } from "./_shared.js";
4
+
5
+ const FREESTYLE_PATH = "/sap/bc/adt/datapreview/freestyle";
6
+ const DEFAULT_MAX_ROWS = 100;
7
+ const HARD_CAP_ROWS = 5000;
8
+
9
+ export const tools = [
10
+ {
11
+ name: "adt_read_table",
12
+ description:
13
+ "Execute an OpenSQL SELECT against the SAP database via the ADT Data Preview endpoint. Read-only by design (SELECT only — INSERT/UPDATE/DELETE rejected client- and server-side). Returns structured { columns, rows }. Use ABAP OpenSQL syntax: 'SELECT matnr matkl FROM mara WHERE matnr LIKE \\'M%\\''. Row count capped at maxRows (default 100, hard cap 5000). Requires NetWeaver 7.55+ or S/4HANA — older systems may not expose this endpoint.",
14
+ inputSchema: {
15
+ type: "object",
16
+ properties: {
17
+ system: { type: "string", description: SYSTEM_HINT },
18
+ query: {
19
+ type: "string",
20
+ description:
21
+ "OpenSQL SELECT statement. Use ABAP syntax (e.g. UP TO N ROWS is allowed but maxRows already caps the result).",
22
+ },
23
+ maxRows: {
24
+ type: "integer",
25
+ description: `Maximum rows to return. Default ${DEFAULT_MAX_ROWS}, max ${HARD_CAP_ROWS}.`,
26
+ minimum: 1,
27
+ maximum: HARD_CAP_ROWS,
28
+ },
29
+ },
30
+ required: ["query"],
31
+ },
32
+ },
33
+ ];
34
+
35
+ export function register({ getClient }) {
36
+ return {
37
+ adt_read_table: async (args) => {
38
+ const guard = validateSelect(args.query);
39
+ if (!guard.ok) {
40
+ return textResult(`adt_read_table: ${guard.reason}`, true);
41
+ }
42
+ const max = Math.min(args.maxRows ?? DEFAULT_MAX_ROWS, HARD_CAP_ROWS);
43
+ const { client, name: sys } = getClient(args.system);
44
+ const res = await client.request({
45
+ method: "POST",
46
+ path: FREESTYLE_PATH,
47
+ query: { rowNumber: String(max) },
48
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
49
+ body: args.query,
50
+ // The Data Preview endpoint content-negotiates strictly: a result set is
51
+ // only serialized for this exact media type. Sending "application/xml"
52
+ // (or anything else) makes the server 406 *after* it has accepted and
53
+ // validated the SELECT — column/syntax errors still come back as 400, so
54
+ // a 406 here means "query was fine, Accept was wrong".
55
+ accept: "application/vnd.sap.adt.datapreview.table.v1+xml",
56
+ });
57
+ const text = await res.text();
58
+ if (!res.ok) return errorResult(sys, res.status, text, res.headers.get("content-type"));
59
+
60
+ let parsed;
61
+ try {
62
+ parsed = parseDataPreview(text);
63
+ } catch (err) {
64
+ return jsonResult({
65
+ system: sys,
66
+ parseError: err.message,
67
+ raw: text.slice(0, 8000),
68
+ });
69
+ }
70
+
71
+ const rowCount = parsed.rows.length;
72
+ return jsonResult({
73
+ system: sys,
74
+ query: args.query,
75
+ rowCount,
76
+ truncated: rowCount >= max,
77
+ totalRows: parsed.totalRows,
78
+ executionTime: parsed.executionTime,
79
+ executedQuery: parsed.executedQuery,
80
+ columns: parsed.columns,
81
+ rows: parsed.rows,
82
+ raw: parsed.columns.length === 0 && rowCount === 0 ? text.slice(0, 4000) : undefined,
83
+ });
84
+ },
85
+ };
86
+ }
@@ -0,0 +1,520 @@
1
+ import { objectUri, sourceUri } from "../object-uris.js";
2
+ import { fetchPackageNodes } from "../node-structure.js";
3
+ import { parseObjectReferences } from "../object-references.js";
4
+ import { errorResult, jsonResult, textResult } from "../result.js";
5
+ import { OBJECT_TYPE_HINT, SYSTEM_HINT } from "./_shared.js";
6
+
7
+ function defaultDescendPrefix(pkg) {
8
+ if (pkg.startsWith("/")) {
9
+ const second = pkg.indexOf("/", 1);
10
+ if (second > 0) return pkg.slice(0, second + 1);
11
+ }
12
+ return pkg[0] ?? "";
13
+ }
14
+
15
+ // Object base-types that expose a fetchable /source/main document. Node types
16
+ // arrive as "CLAS/OC", "PROG/P", "DDLS/DF", … — we key on the part before "/".
17
+ // DDIC primitives (TABL, DTEL, DOMA) and function groups (per-FM includes) are
18
+ // intentionally excluded: no single source document to grep.
19
+ const GREP_SOURCE_TYPES = new Set([
20
+ "PROG",
21
+ "CLAS",
22
+ "INTF",
23
+ "INCL",
24
+ "DDLS",
25
+ "DCLS",
26
+ "DDLX",
27
+ "BDEF",
28
+ ]);
29
+
30
+ function baseType(adtType) {
31
+ return (adtType || "").split("/")[0].toUpperCase();
32
+ }
33
+
34
+ // Resolve a /source/main path for a repository node, or null if the type has no
35
+ // grep-able source document.
36
+ function grepSourcePath(node) {
37
+ const base = baseType(node.type);
38
+ if (!GREP_SOURCE_TYPES.has(base)) return null;
39
+ try {
40
+ return sourceUri({ type: base, name: node.name });
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ // Walk a package (optionally recursively) collecting source-bearing objects up
47
+ // to maxObjects. Returns { targets:[{name,type,path}], skipped:[{name,type}],
48
+ // packagesVisited, truncated }.
49
+ async function collectPackageTargets(client, root, opts) {
50
+ const { recursive, prefix, maxPackages, maxObjects } = opts;
51
+ const visited = new Set();
52
+ const targets = [];
53
+ const skipped = [];
54
+ let truncated = false;
55
+
56
+ async function walk(pkg) {
57
+ if (truncated || visited.size >= maxPackages || visited.has(pkg)) return;
58
+ visited.add(pkg);
59
+ const r = await fetchPackageNodes(client, pkg);
60
+ if (!r.ok) return;
61
+ for (const n of r.nodes) {
62
+ if (n.type === "DEVC/K") continue; // subpackage node — handled below
63
+ if (targets.length >= maxObjects) {
64
+ truncated = true;
65
+ return;
66
+ }
67
+ const path = grepSourcePath(n);
68
+ if (path) targets.push({ name: n.name, type: n.type, path });
69
+ else skipped.push({ name: n.name, type: n.type });
70
+ }
71
+ if (recursive) {
72
+ for (const n of r.nodes) {
73
+ if (n.type === "DEVC/K" && n.name.startsWith(prefix)) await walk(n.name);
74
+ }
75
+ }
76
+ }
77
+
78
+ await walk(root);
79
+ return { targets, skipped, packagesVisited: visited.size, truncated };
80
+ }
81
+
82
+ export const tools = [
83
+ {
84
+ name: "adt_search_objects",
85
+ description:
86
+ "Quick-search the ABAP repository for objects whose name matches a pattern. Use '*' as wildcard.",
87
+ inputSchema: {
88
+ type: "object",
89
+ properties: {
90
+ system: { type: "string", description: SYSTEM_HINT },
91
+ query: {
92
+ type: "string",
93
+ description: "Search pattern, e.g. 'ZCL_CUSTOMER*' or 'Z*INVOICE*'.",
94
+ },
95
+ maxResults: {
96
+ type: "integer",
97
+ description: "Maximum number of results (default 50).",
98
+ minimum: 1,
99
+ maximum: 500,
100
+ },
101
+ objectType: {
102
+ type: "string",
103
+ description:
104
+ "Optional ADT object-type filter, e.g. 'CLAS/OC' for classes, 'PROG/P' for programs. Omit for all types.",
105
+ },
106
+ },
107
+ required: ["query"],
108
+ },
109
+ },
110
+ {
111
+ name: "adt_where_used",
112
+ description: "Where-used list for an object. Returns the references that point to it.",
113
+ inputSchema: {
114
+ type: "object",
115
+ properties: {
116
+ system: { type: "string", description: SYSTEM_HINT },
117
+ object: { type: "string", description: "Object name." },
118
+ type: { type: "string", description: OBJECT_TYPE_HINT },
119
+ group: { type: "string", description: "Function group (for FUGR/FF or FUGR/I)." },
120
+ },
121
+ required: ["object", "type"],
122
+ },
123
+ },
124
+ {
125
+ name: "adt_browse_package",
126
+ description:
127
+ "List the immediate contents (one level) of an ABAP package. Use adt_list_packages for recursive walks.",
128
+ inputSchema: {
129
+ type: "object",
130
+ properties: {
131
+ system: { type: "string", description: SYSTEM_HINT },
132
+ package: {
133
+ type: "string",
134
+ description: "Package name (case-insensitive), e.g. 'ZLOCAL' or '/MYNS/MAIN'.",
135
+ },
136
+ },
137
+ required: ["package"],
138
+ },
139
+ },
140
+ {
141
+ name: "adt_list_packages",
142
+ description:
143
+ "Recursively walk subpackages from a root package. Returns a flattened map of package → contents (counts and entries grouped by type).",
144
+ inputSchema: {
145
+ type: "object",
146
+ properties: {
147
+ system: { type: "string", description: SYSTEM_HINT },
148
+ root: { type: "string", description: "Root package name to walk from." },
149
+ prefix: {
150
+ type: "string",
151
+ description:
152
+ "Only descend into subpackages whose name starts with this prefix. Defaults to namespace prefix or first character.",
153
+ },
154
+ maxPackages: {
155
+ type: "integer",
156
+ description: "Safety limit on total packages visited (default 200).",
157
+ minimum: 1,
158
+ maximum: 5000,
159
+ },
160
+ },
161
+ required: ["root"],
162
+ },
163
+ },
164
+ {
165
+ name: "adt_grep_source",
166
+ description:
167
+ "Full-text regex search across ABAP source. Complements adt_search_objects (which only matches names). Scope the search to a package (optionally recursive), a transport request, or an explicit object list. Fetches /source/main for each source-bearing object (programs, classes, interfaces, includes, CDS/DDLS, DCLS, DDLX, behavior definitions) and returns matching lines as object + line + text. DDIC primitives (tables, data elements, domains) and function groups are skipped (no single source document). Bounded by maxObjects and maxMatches — large packages are truncated, not silently capped.",
168
+ inputSchema: {
169
+ type: "object",
170
+ properties: {
171
+ system: { type: "string", description: SYSTEM_HINT },
172
+ pattern: {
173
+ type: "string",
174
+ description:
175
+ "JavaScript regular expression to match against each source line, e.g. 'SELECT \\\\* FROM' or 'CALL FUNCTION'. Anchors and groups allowed.",
176
+ },
177
+ flags: {
178
+ type: "string",
179
+ description:
180
+ "RegExp flags. Default 'i' (case-insensitive). 'g' is ignored (matching is per-line). Use '' for case-sensitive.",
181
+ },
182
+ package: {
183
+ type: "string",
184
+ description: "Scope: search every source object in this package. Mutually exclusive with transport / objects.",
185
+ },
186
+ recursive: {
187
+ type: "boolean",
188
+ description: "When package is set, also descend into subpackages (prefix-filtered). Default false.",
189
+ },
190
+ prefix: {
191
+ type: "string",
192
+ description: "Only descend into subpackages whose name starts with this prefix (recursive only). Defaults to namespace prefix / first char.",
193
+ },
194
+ maxPackages: {
195
+ type: "integer",
196
+ description: "Safety limit on packages visited when recursive (default 200).",
197
+ minimum: 1,
198
+ maximum: 5000,
199
+ },
200
+ transport: {
201
+ type: "string",
202
+ description: "Scope: search every object referenced by this transport request.",
203
+ },
204
+ objects: {
205
+ type: "array",
206
+ description: "Scope: explicit object list. Each item { name, type }.",
207
+ items: {
208
+ type: "object",
209
+ properties: {
210
+ name: { type: "string" },
211
+ type: { type: "string", description: OBJECT_TYPE_HINT },
212
+ },
213
+ required: ["name", "type"],
214
+ },
215
+ },
216
+ maxObjects: {
217
+ type: "integer",
218
+ description: "Maximum source objects to fetch (default 100).",
219
+ minimum: 1,
220
+ maximum: 2000,
221
+ },
222
+ maxMatches: {
223
+ type: "integer",
224
+ description: "Maximum matching lines to return across all objects (default 200).",
225
+ minimum: 1,
226
+ maximum: 5000,
227
+ },
228
+ },
229
+ required: ["pattern"],
230
+ },
231
+ },
232
+ ];
233
+
234
+ export function register({ getClient }) {
235
+ return {
236
+ adt_search_objects: async (args) => {
237
+ const { client, name: sys } = getClient(args.system);
238
+ const maxResults = args.maxResults ?? 50;
239
+ const baseQuery = {
240
+ query: args.query,
241
+ maxResults: String(maxResults),
242
+ };
243
+ if (args.objectType) baseQuery.objectType = args.objectType;
244
+
245
+ // GET, not POST: POSTing to this path routes to the RIS object-search
246
+ // handler, which demands a `ris_request_type` query parameter we don't
247
+ // supply and rejects the call with 400 "Parameter ris_request_type could
248
+ // not be found". The quickSearch operation is a GET contract (the one ADT
249
+ // Eclipse uses) and needs no such parameter.
250
+ const tryRequest = (extra) =>
251
+ client.request({
252
+ method: "GET",
253
+ path: "/sap/bc/adt/repository/informationsystem/search",
254
+ query: { ...baseQuery, ...extra },
255
+ });
256
+
257
+ let res = await tryRequest({ operation: "quickSearch" });
258
+ let text = await res.text();
259
+ // Older NetWeaver releases don't deploy the quickSearch operation and
260
+ // return a 500 "No service found for ID quickSearch". Fall back to the
261
+ // operation-less legacy search endpoint shape.
262
+ let usedFallback = false;
263
+ if (!res.ok && /No service found for ID\s+quickSearch/i.test(text)) {
264
+ usedFallback = true;
265
+ res = await tryRequest({});
266
+ text = await res.text();
267
+ }
268
+ if (!res.ok) return errorResult(sys, res.status, text, res.headers.get("content-type"));
269
+ const refs = parseObjectReferences(text);
270
+ return jsonResult({
271
+ system: sys,
272
+ query: args.query,
273
+ count: refs.length,
274
+ hasMore: refs.length >= maxResults,
275
+ results: refs,
276
+ ...(usedFallback ? { operation: "legacy" } : {}),
277
+ });
278
+ },
279
+
280
+ adt_where_used: async (args) => {
281
+ const { client, name: sys } = getClient(args.system);
282
+ const uri = objectUri({ type: args.type, name: args.object, group: args.group });
283
+ const res = await client.request({
284
+ method: "POST",
285
+ path: "/sap/bc/adt/repository/informationsystem/usageReferences",
286
+ query: { uri },
287
+ // Without this the server rejects the POST with 400 "Content type
288
+ // missing" — it hits DDIC objects (tables, structures) where the tool
289
+ // sent no request entity. The typed (empty) body is what ADT expects to
290
+ // mean "all usages of <uri>".
291
+ headers: {
292
+ "Content-Type":
293
+ "application/vnd.sap.adt.repository.usageReferences.request.v1+xml",
294
+ },
295
+ });
296
+ const text = await res.text();
297
+ if (!res.ok) return errorResult(sys, res.status, text, res.headers.get("content-type"));
298
+ const refs = parseObjectReferences(text);
299
+ return jsonResult({
300
+ system: sys,
301
+ object: args.object,
302
+ count: refs.length,
303
+ references: refs,
304
+ raw: refs.length === 0 ? text : undefined,
305
+ });
306
+ },
307
+
308
+ adt_browse_package: async (args) => {
309
+ const { client, name: sys } = getClient(args.system);
310
+ const pkg = args.package.toUpperCase();
311
+ const r = await fetchPackageNodes(client, pkg);
312
+ if (!r.ok) return errorResult(sys, r.status, r.body);
313
+ return jsonResult({ system: sys, package: pkg, total: r.nodes.length, entries: r.nodes });
314
+ },
315
+
316
+ adt_list_packages: async (args) => {
317
+ const { client, name: sys } = getClient(args.system);
318
+ const root = args.root.toUpperCase();
319
+ const max = args.maxPackages ?? 200;
320
+ const prefix = args.prefix ?? defaultDescendPrefix(root);
321
+
322
+ const visited = new Set();
323
+ const packages = {};
324
+
325
+ async function walk(pkg) {
326
+ if (visited.size >= max) return;
327
+ if (visited.has(pkg)) return;
328
+ visited.add(pkg);
329
+ const r = await fetchPackageNodes(client, pkg);
330
+ if (!r.ok) {
331
+ packages[pkg] = { error: { status: r.status } };
332
+ return;
333
+ }
334
+ const byType = {};
335
+ for (const n of r.nodes) {
336
+ (byType[n.type] = byType[n.type] || []).push({
337
+ name: n.name,
338
+ description: n.description,
339
+ });
340
+ }
341
+ packages[pkg] = {
342
+ counts: Object.fromEntries(
343
+ Object.entries(byType).map(([k, v]) => [k, v.length])
344
+ ),
345
+ entries: byType,
346
+ };
347
+ for (const n of r.nodes) {
348
+ if (n.type === "DEVC/K" && n.name.startsWith(prefix)) {
349
+ await walk(n.name);
350
+ }
351
+ }
352
+ }
353
+
354
+ await walk(root);
355
+
356
+ return jsonResult({
357
+ system: sys,
358
+ root,
359
+ prefix,
360
+ packagesVisited: visited.size,
361
+ truncated: visited.size >= max,
362
+ packages,
363
+ });
364
+ },
365
+
366
+ adt_grep_source: async (args) => {
367
+ const { client, name: sys } = getClient(args.system);
368
+
369
+ const flags = (args.flags ?? "i").replace(/[gy]/g, "");
370
+ let re;
371
+ try {
372
+ re = new RegExp(args.pattern, flags);
373
+ } catch (e) {
374
+ return textResult(
375
+ `adt_grep_source: invalid regex /${args.pattern}/${flags}: ${e.message}`,
376
+ true,
377
+ );
378
+ }
379
+
380
+ const hasObjects = Array.isArray(args.objects) && args.objects.length > 0;
381
+ const scopeCount = [args.package, args.transport, hasObjects].filter(Boolean).length;
382
+ if (scopeCount !== 1) {
383
+ return textResult(
384
+ "adt_grep_source: provide exactly one scope — package, transport, or objects.",
385
+ true,
386
+ );
387
+ }
388
+
389
+ const maxObjects = args.maxObjects ?? 100;
390
+ const maxMatches = args.maxMatches ?? 200;
391
+
392
+ let targets = [];
393
+ let skipped = [];
394
+ let scope;
395
+ let packagesVisited;
396
+ let scopeTruncated = false;
397
+
398
+ if (args.package) {
399
+ const root = args.package.toUpperCase();
400
+ scope = `package:${root}`;
401
+ const prefix = args.prefix ?? defaultDescendPrefix(root);
402
+ const r = await collectPackageTargets(client, root, {
403
+ recursive: !!args.recursive,
404
+ prefix,
405
+ maxPackages: args.maxPackages ?? 200,
406
+ maxObjects,
407
+ });
408
+ targets = r.targets;
409
+ skipped = r.skipped;
410
+ packagesVisited = r.packagesVisited;
411
+ scopeTruncated = r.truncated;
412
+ } else if (args.transport) {
413
+ const trId = args.transport.toUpperCase();
414
+ scope = `transport:${trId}`;
415
+ const trRes = await client.request({
416
+ path: `/sap/bc/adt/cts/transportrequests/${encodeURIComponent(trId)}`,
417
+ });
418
+ const trBody = await trRes.text();
419
+ if (!trRes.ok) {
420
+ return errorResult(sys, trRes.status, trBody, trRes.headers.get("content-type"), {
421
+ stage: "fetch-transport",
422
+ });
423
+ }
424
+ for (const ref of parseObjectReferences(trBody)) {
425
+ if (!ref.uri) continue;
426
+ if (targets.length >= maxObjects) {
427
+ scopeTruncated = true;
428
+ break;
429
+ }
430
+ if (GREP_SOURCE_TYPES.has(baseType(ref.type))) {
431
+ const uri = ref.uri.split("?")[0];
432
+ const path = uri.endsWith("/source/main") ? uri : `${uri}/source/main`;
433
+ targets.push({ name: ref.name, type: ref.type, path });
434
+ } else {
435
+ skipped.push({ name: ref.name, type: ref.type });
436
+ }
437
+ }
438
+ } else {
439
+ scope = `objects:${args.objects.length}`;
440
+ for (const o of args.objects) {
441
+ if (targets.length >= maxObjects) {
442
+ scopeTruncated = true;
443
+ break;
444
+ }
445
+ try {
446
+ targets.push({
447
+ name: o.name,
448
+ type: o.type,
449
+ path: sourceUri({ type: o.type, name: o.name, group: o.group }),
450
+ });
451
+ } catch (e) {
452
+ skipped.push({ name: o.name, type: o.type, reason: e.message });
453
+ }
454
+ }
455
+ }
456
+
457
+ const matches = [];
458
+ const scanned = [];
459
+ let matchTruncated = false;
460
+ for (const t of targets) {
461
+ if (matches.length >= maxMatches) {
462
+ matchTruncated = true;
463
+ break;
464
+ }
465
+ let res;
466
+ let body;
467
+ try {
468
+ res = await client.request({ path: t.path, accept: "text/plain" });
469
+ body = await res.text();
470
+ } catch (e) {
471
+ scanned.push({ name: t.name, type: t.type, error: e.message });
472
+ continue;
473
+ }
474
+ if (!res.ok) {
475
+ scanned.push({ name: t.name, type: t.type, error: `HTTP ${res.status}` });
476
+ continue;
477
+ }
478
+ const lines = body.split(/\r?\n/);
479
+ let hits = 0;
480
+ for (let i = 0; i < lines.length; i++) {
481
+ re.lastIndex = 0;
482
+ if (re.test(lines[i])) {
483
+ matches.push({
484
+ object: t.name,
485
+ type: t.type,
486
+ line: i + 1,
487
+ text: lines[i].slice(0, 300),
488
+ });
489
+ hits++;
490
+ if (matches.length >= maxMatches) {
491
+ matchTruncated = true;
492
+ break;
493
+ }
494
+ }
495
+ }
496
+ scanned.push({ name: t.name, type: t.type, hits });
497
+ }
498
+
499
+ return jsonResult({
500
+ system: sys,
501
+ pattern: args.pattern,
502
+ flags,
503
+ scope,
504
+ packagesVisited,
505
+ objectsScanned: scanned.length,
506
+ objectsSkipped: skipped.length,
507
+ matchCount: matches.length,
508
+ truncated: scopeTruncated || matchTruncated,
509
+ truncationReason: scopeTruncated
510
+ ? "maxObjects"
511
+ : matchTruncated
512
+ ? "maxMatches"
513
+ : undefined,
514
+ matches,
515
+ errors: scanned.filter((s) => s.error).slice(0, 50),
516
+ skipped: skipped.slice(0, 50),
517
+ });
518
+ },
519
+ };
520
+ }