sap-adt-mcp 0.8.0 → 0.8.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/README.md CHANGED
@@ -161,32 +161,41 @@ disables TLS validation for that profile only. Don't set this on PRD.
161
161
 
162
162
  ### Automatic error reporting
163
163
 
164
- When a tool call fails with an **unexpected** error, the server sends a small,
165
- **redacted** crash report to the maintainer so the bug can be found and fixed.
166
- This is **on by default** and the server prints a notice saying so on startup.
164
+ The server sends small, **redacted** reports to the maintainer so defects get
165
+ found and fixed. This is **on by default** and the server prints a notice saying
166
+ so on startup. There are three channels:
167
+
168
+ 1. **Crash** — a tool handler throws an unexpected error.
169
+ 2. **ADT error** — a tool returns a non-2xx ADT response that the classifier
170
+ flags as a likely tool bug (406/415 content negotiation, malformed requests,
171
+ server dispatcher blow-ups). User/business-side responses (401/403/404, lock
172
+ and enqueue conflicts, data-preview SQL errors) are **not** reported.
173
+ 3. **Agent-reported** — the calling agent files a defect the other two channels
174
+ can't see (wrong data in a successful response, an ignored parameter, a
175
+ missing capability) via the **`adt_report_issue`** tool.
167
176
 
168
177
  What is sent: the sap-adt-mcp version, Node version, OS, the tool name, and the
169
- error message + stack with a fingerprint for de-duplication. Before anything
170
- leaves your machine it is scrubbed of **hostnames, users, passwords, tokens,
171
- IPs, and emails**, and tool arguments are redacted the same way. Reports go to a
172
- relay the maintainer owns, which files/de-dups a GitHub issue — the relay holds
173
- the GitHub credentials, never this package.
178
+ error/finding with a fingerprint for de-duplication. Before anything leaves your
179
+ machine it is scrubbed of **hostnames, users, passwords, tokens, IPs, and
180
+ emails**; tool arguments and free-text fields are redacted the same way. Reports
181
+ go to a relay the maintainer owns, which files/de-dups a GitHub issue — the
182
+ relay holds the GitHub credentials, never this package.
174
183
 
175
- What is **not** sent: expected/user-side failures (read-only violations, network
176
- or TLS problems, wrong credentials, config mistakes) are never reported.
177
-
178
- Turn it off completely:
184
+ Turn it all off:
179
185
 
180
186
  ```json
181
- {
182
- "reporting": { "enabled": false }
183
- }
187
+ { "reporting": { "enabled": false } }
184
188
  ```
185
189
 
186
- …or set `SAP_ADT_MCP_REPORT=0` (also accepts `false`/`no`/`off`). To keep
187
- reporting but exclude tool arguments, set `"reporting": { "includeArgs": false }`.
188
- To point at your own relay, set `"reporting": { "endpoint": "https://..." }`
189
- (see [`worker/`](worker/) for the relay implementation).
190
+ …or set `SAP_ADT_MCP_REPORT=0` (also accepts `false`/`no`/`off`). Finer control:
191
+
192
+ | Key | Default | Effect |
193
+ | --- | --- | --- |
194
+ | `reporting.enabled` | `true` | Master switch for all three channels. |
195
+ | `reporting.adtErrors` | `true` | Channel 2 (auto-report flagged ADT errors). |
196
+ | `reporting.allowManual` | `true` | Channel 3 (the `adt_report_issue` tool). |
197
+ | `reporting.includeArgs` | `true` | Include redacted tool args / repro args. Note: object names can appear here. |
198
+ | `reporting.endpoint` | relay URL | Point at your own relay (see [`worker/`](worker/)). |
190
199
 
191
200
  ## Connect a client
192
201
 
@@ -4,7 +4,9 @@
4
4
  "readOnly": false,
5
5
  "reporting": {
6
6
  "enabled": true,
7
- "includeArgs": true
7
+ "includeArgs": true,
8
+ "adtErrors": true,
9
+ "allowManual": true
8
10
  },
9
11
  "systems": {
10
12
  "DEV": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sap-adt-mcp",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "MCP server giving Claude live access to SAP systems via ADT (ABAP Development Tools) REST. Read source, search, run syntax checks, and edit ABAP objects from any MCP-compatible client.",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "scripts": {
21
21
  "start": "node src/server.js",
22
- "test": "node --test test/adt-error.test.js test/atc-bulk.test.js test/cds.test.js test/data-preview.test.js test/diff.test.js test/dump-feed.test.js test/grep-source.test.js test/jobs.test.js test/lifecycle-activate.test.js test/lock.test.js test/mcp-bug-fixes.test.js test/node-structure.test.js test/notes.test.js test/object-create.test.js test/object-references.test.js test/object-uris.test.js test/prompts.test.js test/provenance.test.js test/rap-scaffold.test.js test/reporter.test.js test/security.test.js test/source-chunked.test.js test/source-guard.test.js test/source-pagination.test.js test/tools-shape.test.js test/versions.test.js test/worklist.test.js",
22
+ "test": "node --test test/adt-error.test.js test/atc-bulk.test.js test/cds.test.js test/data-preview.test.js test/diff.test.js test/dump-feed.test.js test/grep-source.test.js test/jobs.test.js test/lifecycle-activate.test.js test/lock.test.js test/mcp-bug-fixes.test.js test/node-structure.test.js test/notes.test.js test/object-create.test.js test/object-references.test.js test/object-uris.test.js test/prompts.test.js test/provenance.test.js test/rap-scaffold.test.js test/reporter.test.js test/security.test.js test/source-chunked.test.js test/source-guard.test.js test/source-pagination.test.js test/syntax-context.test.js test/tools-shape.test.js test/versions.test.js test/worklist.test.js",
23
23
  "lint": "eslint src test"
24
24
  },
25
25
  "keywords": [
package/src/config.js CHANGED
@@ -65,6 +65,10 @@ function parseReporting(raw) {
65
65
  enabled: envOff ? false : r.enabled !== false,
66
66
  endpoint: typeof r.endpoint === "string" && r.endpoint ? r.endpoint : null,
67
67
  includeArgs: r.includeArgs !== false,
68
+ // Channel 1: auto-report selected non-2xx ADT results (406/415/malformed).
69
+ adtErrors: r.adtErrors !== false,
70
+ // Channel 2: allow the adt_report_issue tool to file agent-initiated reports.
71
+ allowManual: r.allowManual !== false,
68
72
  };
69
73
  }
70
74
 
@@ -54,6 +54,13 @@ const ROW_RE = /<[a-zA-Z]+:row\b[^>]*>([\s\S]*?)<\/[a-zA-Z]+:row>/g;
54
54
  const VALUE_RE = /<[a-zA-Z]+:value\b[^>]*>([\s\S]*?)<\/[a-zA-Z]+:value>/g;
55
55
  const DATA_CELL_RE =
56
56
  /<[a-zA-Z]+:data\b[^>]*\b(?:dataPreview:)?columnName="([^"]+)"[^>]*>([\s\S]*?)<\/[a-zA-Z]+:data>/g;
57
+ // Column-major table.v1+xml: one <columns> block per column, each carrying a
58
+ // <metadata> (the column) and a <dataSet> of <data> cells, one per row.
59
+ const COLUMN_BLOCK_RE =
60
+ /<[a-zA-Z]+:columns\b[^>]*>([\s\S]*?)<\/[a-zA-Z]+:columns>/g;
61
+ // A <data> cell, matching both <data>val</data> and self-closing <data/> (null).
62
+ const DATA_RE =
63
+ /<[a-zA-Z]+:data\b[^>]*?(?:\/>|>([\s\S]*?)<\/[a-zA-Z]+:data>)/g;
57
64
  const ATTR_RE = (attr) =>
58
65
  new RegExp(`\\b(?:[a-zA-Z]+:)?${attr}="([^"]*)"`, "i");
59
66
 
@@ -77,6 +84,35 @@ function pickTagText(xml, tag) {
77
84
  return m ? decodeEntities(m[1].trim()) : undefined;
78
85
  }
79
86
 
87
+ // Parse the column-major table.v1+xml shape into row objects. Each
88
+ // <dataPreview:columns> block holds one <metadata> (the column) and a
89
+ // <dataSet> whose <data> children are that column's values across all rows.
90
+ // Returns [] when the document isn't column-major (no per-column data cells).
91
+ function parseColumnMajor(xml) {
92
+ const cols = [];
93
+ let nrows = 0;
94
+ for (const block of xml.matchAll(COLUMN_BLOCK_RE)) {
95
+ const inner = block[1];
96
+ const metaMatch = inner.match(/<[a-zA-Z]+:metadata\b([^>]*)>/);
97
+ const name = metaMatch ? pickAttr(metaMatch[1], "name") : undefined;
98
+ const values = [...inner.matchAll(DATA_RE)].map((d) =>
99
+ decodeEntities((d[1] ?? "").trim())
100
+ );
101
+ // A metadata-only <columns> wrapper (Shape A) carries no data cells — skip.
102
+ if (!name || values.length === 0) continue;
103
+ cols.push({ name, values });
104
+ if (values.length > nrows) nrows = values.length;
105
+ }
106
+ if (cols.length === 0) return [];
107
+ const rows = [];
108
+ for (let i = 0; i < nrows; i++) {
109
+ const row = {};
110
+ for (const c of cols) row[c.name] = c.values[i] ?? null;
111
+ rows.push(row);
112
+ }
113
+ return rows;
114
+ }
115
+
80
116
  export function parseDataPreview(xml) {
81
117
  const columns = [];
82
118
  for (const m of xml.matchAll(METADATA_RE)) {
@@ -114,21 +150,28 @@ export function parseDataPreview(xml) {
114
150
  }
115
151
  }
116
152
  } else {
117
- // Shape B: flat <data columnName="X">val</data> blocks. We group every N
118
- // cells into a row where N = column count.
119
- const cells = [...xml.matchAll(DATA_CELL_RE)].map((m) => ({
120
- column: m[1],
121
- value: decodeEntities(m[2].trim()),
122
- }));
123
- if (columns.length > 0 && cells.length > 0) {
124
- const stride = columns.length;
125
- for (let i = 0; i < cells.length; i += stride) {
126
- const row = {};
127
- for (let j = 0; j < stride && i + j < cells.length; j++) {
128
- const cell = cells[i + j];
129
- row[cell.column] = cell.value;
153
+ // Shape C: column-major table.v1+xml each <columns> block carries the
154
+ // column metadata plus its own <dataSet> of cells. Transpose to rows.
155
+ const colMajor = parseColumnMajor(xml);
156
+ if (colMajor.length > 0) {
157
+ rows.push(...colMajor);
158
+ } else {
159
+ // Shape B: flat <data columnName="X">val</data> blocks. We group every N
160
+ // cells into a row where N = column count.
161
+ const cells = [...xml.matchAll(DATA_CELL_RE)].map((m) => ({
162
+ column: m[1],
163
+ value: decodeEntities(m[2].trim()),
164
+ }));
165
+ if (columns.length > 0 && cells.length > 0) {
166
+ const stride = columns.length;
167
+ for (let i = 0; i < cells.length; i += stride) {
168
+ const row = {};
169
+ for (let j = 0; j < stride && i + j < cells.length; j++) {
170
+ const cell = cells[i + j];
171
+ row[cell.column] = cell.value;
172
+ }
173
+ rows.push(row);
130
174
  }
131
- rows.push(row);
132
175
  }
133
176
  }
134
177
  }
package/src/dump-feed.js CHANGED
@@ -92,6 +92,16 @@ export function parseDumpFeed(xml) {
92
92
  return entries;
93
93
  }
94
94
 
95
+ // Client-side user filter. Several on-prem releases ignore the feed's `user`
96
+ // query parameter and return every user's dumps, so we enforce the filter here
97
+ // the same way adt_list_dumps already trims maxResults client-side. Match is
98
+ // case-insensitive on the author/user the feed parser surfaced per entry.
99
+ export function filterDumpsByUser(entries, user) {
100
+ if (!user) return entries;
101
+ const want = String(user).trim().toUpperCase();
102
+ return entries.filter((e) => (e.user ?? "").trim().toUpperCase() === want);
103
+ }
104
+
95
105
  export function parseDumpDetail(xml) {
96
106
  // The detail response can be either an Atom entry or a richer rba-namespaced
97
107
  // document; surface what we can extract plus the raw body.
@@ -125,6 +125,17 @@ export function objectUri({ type, name, group }) {
125
125
  }
126
126
  }
127
127
 
128
+ // DDIC primitives don't serve plain-text source at their object URI — they
129
+ // return XML metadata behind a dedicated media type. Requesting "text/plain"
130
+ // makes the resource answer 406 ExceptionResourceNotAcceptable. Map the type to
131
+ // the Accept it actually serves; consistent with the v2 media types this
132
+ // codebase already uses for domains/message classes elsewhere.
133
+ export const METADATA_XML_ACCEPT = {
134
+ DTEL: "application/vnd.sap.adt.dataelements.v2+xml",
135
+ DOMA: "application/vnd.sap.adt.domains.v2+xml",
136
+ MSAG: "application/vnd.sap.adt.messageclass.v2+xml",
137
+ };
138
+
128
139
  export function sourceUri({ type, name, group, include }) {
129
140
  const t = normalizeType(type);
130
141
  const base = objectUri({ type: t, name, group });
package/src/reporter.js CHANGED
@@ -123,16 +123,84 @@ function appFrame(stack) {
123
123
  return "";
124
124
  }
125
125
 
126
- function fingerprint(err) {
127
- const norm = String(err.message ?? "")
126
+ function hash16(s) {
127
+ return crypto.createHash("sha256").update(s).digest("hex").slice(0, 16);
128
+ }
129
+
130
+ function normalizeText(s) {
131
+ return String(s ?? "")
128
132
  .replace(/0x[0-9a-f]+/gi, "")
129
133
  .replace(/['"`][^'"`]*['"`]/g, "")
130
134
  .replace(/\d+/g, "#")
131
135
  .replace(/\s+/g, " ")
132
136
  .trim()
133
137
  .toLowerCase();
134
- const basis = `${err.name ?? "Error"}|${norm}|${appFrame(err.stack)}`;
135
- return crypto.createHash("sha256").update(basis).digest("hex").slice(0, 16);
138
+ }
139
+
140
+ function fingerprint(err) {
141
+ const basis = `${err.name ?? "Error"}|${normalizeText(err.message)}|${appFrame(err.stack)}`;
142
+ return hash16(basis);
143
+ }
144
+
145
+ function safeStringify(v) {
146
+ try {
147
+ return JSON.stringify(v);
148
+ } catch {
149
+ return "<unserializable>";
150
+ }
151
+ }
152
+
153
+ // --- ADT errorResult classification (channel 1) ------------------------------
154
+
155
+ // Known business / user-side T100 message ids and exception types: never a tool
156
+ // bug, so never auto-reported.
157
+ const BUSINESS_T100 = new Set([
158
+ "SLOCK",
159
+ "S_LOCK",
160
+ "CTS_WBO_API",
161
+ "ADT_DATAPREVIEW_MSG",
162
+ ]);
163
+ const BUSINESS_TYPE_RE =
164
+ /SaveFailure|Lock|Enqueue|CTS_|ExceptionResourceAlreadyExists|NotFound/i;
165
+
166
+ // Decide whether a non-2xx ADT response that a handler returned (not threw) is
167
+ // likely a defect in *this* tool rather than a user/business condition. Errs
168
+ // toward NOT reporting; the relay labels survivors `auto-adt-error` for cheap
169
+ // human triage.
170
+ function shouldReportAdt(meta = {}) {
171
+ const s = Number(meta.status);
172
+ const type = String(meta.type ?? "");
173
+ const t100id = String(meta.t100?.id ?? "");
174
+ const msg = String(meta.message ?? "");
175
+
176
+ if (BUSINESS_T100.has(t100id)) return false;
177
+ if (BUSINESS_TYPE_RE.test(type)) return false;
178
+ if (/datapreview/i.test(type) || /datapreview/i.test(t100id)) return false;
179
+
180
+ // Content negotiation: historically always a missing/wrong header on our side.
181
+ if (s === 406 || s === 415) return true;
182
+ // Clear user/business statuses.
183
+ if (s === 401 || s === 403 || s === 404 || s === 409 || s === 423) return false;
184
+ // 400: report only when it reads like a malformed request (bad/missing media
185
+ // type or query parameter) rather than a data/syntax error.
186
+ if (s === 400) {
187
+ return /content[- ]?type|not acceptable|media type|missing|could not be found|parameter/i.test(
188
+ msg + " " + type
189
+ );
190
+ }
191
+ // Server-side dispatcher/parser blow-ups on a request we shaped.
192
+ if (s >= 500) return true;
193
+ return false;
194
+ }
195
+
196
+ function adtFingerprint(meta) {
197
+ return hash16(
198
+ `adt|${meta.tool ?? ""}|${meta.status ?? ""}|${meta.type ?? ""}|${meta.t100?.id ?? ""}|${meta.t100?.number ?? ""}`
199
+ );
200
+ }
201
+
202
+ function manualFingerprint(tool, kind, summary) {
203
+ return hash16(`manual|${tool}|${kind}|${normalizeText(summary)}`);
136
204
  }
137
205
 
138
206
  // --- Reporter factory --------------------------------------------------------
@@ -142,16 +210,20 @@ export function createReporter(config, pkg) {
142
210
  const enabled = rep.enabled !== false;
143
211
  const endpoint = rep.endpoint || DEFAULT_ENDPOINT;
144
212
  const includeArgs = rep.includeArgs !== false;
213
+ const adtErrors = rep.adtErrors !== false;
214
+ const allowManual = rep.allowManual !== false;
145
215
  const redact = makeRedactor(collectSecrets(config.systems));
146
216
  const seen = new Set(); // per-process de-dup: one POST per fingerprint per run.
147
217
 
148
- async function send(payload) {
218
+ // source = the x-report-source header (crash/adt-error use "sap-adt-mcp";
219
+ // agent-initiated reports use "sap-adt-mcp-manual"). The relay routes on it.
220
+ async function send(payload, source) {
149
221
  try {
150
222
  await fetch(endpoint, {
151
223
  method: "POST",
152
224
  headers: {
153
225
  "content-type": "application/json",
154
- "x-report-source": "sap-adt-mcp",
226
+ "x-report-source": source,
155
227
  },
156
228
  body: JSON.stringify(payload),
157
229
  signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
@@ -161,6 +233,19 @@ export function createReporter(config, pkg) {
161
233
  }
162
234
  }
163
235
 
236
+ function envelope(kind, fingerprint) {
237
+ return {
238
+ kind,
239
+ fingerprint,
240
+ build: BUILD_FINGERPRINT,
241
+ version: pkg.version,
242
+ node: process.version,
243
+ os: `${os.platform()} ${os.release()}`,
244
+ timestamp: new Date().toISOString(),
245
+ };
246
+ }
247
+
248
+ // Channel 0 — crash: a tool handler threw an unexpected error.
164
249
  function report(err, context = {}) {
165
250
  try {
166
251
  if (!enabled) return;
@@ -170,34 +255,103 @@ export function createReporter(config, pkg) {
170
255
  seen.add(fp);
171
256
 
172
257
  const payload = {
173
- fingerprint: fp,
174
- build: BUILD_FINGERPRINT,
175
- version: pkg.version,
176
- node: process.version,
177
- os: `${os.platform()} ${os.release()}`,
258
+ ...envelope("crash", fp),
178
259
  tool: context.tool ?? null,
179
260
  errorName: err.name ?? "Error",
180
261
  message: redact(err.message ?? ""),
181
262
  stack: redact(err.stack ?? ""),
182
- timestamp: new Date().toISOString(),
183
263
  };
184
264
  if (includeArgs && context.args !== undefined) {
185
- let dump;
186
- try {
187
- dump = JSON.stringify(context.args);
188
- } catch {
189
- dump = "<unserializable>";
190
- }
191
- payload.args = redact(dump);
265
+ payload.args = redact(safeStringify(context.args));
192
266
  }
193
- return send(payload); // returned for tests; intentionally not awaited by callers.
267
+ return send(payload, "sap-adt-mcp");
194
268
  } catch {
195
269
  // A reporter that crashes the tool would be worse than no reporter.
196
270
  }
197
271
  }
198
272
 
199
- return { enabled, endpoint, report };
273
+ // Channel 1 adt-error: a handler RETURNED a non-2xx ADT result that the
274
+ // classifier judges a likely tool defect (content negotiation, malformed
275
+ // request, server dispatcher blow-up).
276
+ function reportAdtError(meta = {}) {
277
+ try {
278
+ if (!enabled || !adtErrors) return;
279
+ if (!shouldReportAdt(meta)) return;
280
+ const fp = adtFingerprint(meta);
281
+ if (seen.has(fp)) return;
282
+ seen.add(fp);
283
+
284
+ const payload = {
285
+ ...envelope("adt-error", fp),
286
+ tool: meta.tool ?? null,
287
+ status: meta.status ?? null,
288
+ errorType: meta.type ?? null,
289
+ namespace: meta.namespace ?? null,
290
+ t100: meta.t100 ?? null,
291
+ message: redact(meta.message ?? ""),
292
+ };
293
+ if (includeArgs && meta.args !== undefined) {
294
+ payload.args = redact(safeStringify(meta.args));
295
+ }
296
+ return send(payload, "sap-adt-mcp");
297
+ } catch {
298
+ // best-effort
299
+ }
300
+ }
301
+
302
+ // Channel 2 — manual: the calling agent files a defect the classifier can't
303
+ // see (wrong data in a 200, ignored parameter, missing capability). Returns a
304
+ // small status object to the tool so the agent gets feedback.
305
+ function reportManual(input = {}) {
306
+ try {
307
+ if (!enabled) return { ok: false, reason: "reporting disabled" };
308
+ if (!allowManual) return { ok: false, reason: "manual reporting disabled" };
309
+ const tool = String(input.tool ?? "").trim();
310
+ const summary = String(input.summary ?? "").trim();
311
+ if (!tool || !summary) {
312
+ return { ok: false, reason: "tool and summary are required" };
313
+ }
314
+ const issueKind = input.kind === "enhancement" ? "enhancement" : "bug";
315
+ const fp = manualFingerprint(tool, issueKind, summary);
316
+
317
+ const payload = {
318
+ ...envelope("manual", fp),
319
+ tool,
320
+ issueKind,
321
+ summary: redact(summary),
322
+ expected: input.expected ? redact(String(input.expected)) : undefined,
323
+ actual: input.actual ? redact(String(input.actual)) : undefined,
324
+ };
325
+ if (includeArgs && input.reproArgs !== undefined) {
326
+ payload.reproArgs = redact(safeStringify(input.reproArgs));
327
+ }
328
+ // Explicit user action — no per-process de-dup here; the relay collapses
329
+ // repeats onto one issue by fingerprint.
330
+ send(payload, "sap-adt-mcp-manual");
331
+ return { ok: true, fingerprint: fp, issueKind };
332
+ } catch {
333
+ return { ok: false, reason: "internal error" };
334
+ }
335
+ }
336
+
337
+ return {
338
+ enabled,
339
+ endpoint,
340
+ adtErrors,
341
+ allowManual,
342
+ report,
343
+ reportAdtError,
344
+ reportManual,
345
+ };
200
346
  }
201
347
 
202
348
  // Exposed for unit tests.
203
- export const _internals = { shouldReport, fingerprint, collectSecrets, makeRedactor };
349
+ export const _internals = {
350
+ shouldReport,
351
+ shouldReportAdt,
352
+ fingerprint,
353
+ adtFingerprint,
354
+ manualFingerprint,
355
+ collectSecrets,
356
+ makeRedactor,
357
+ };
package/src/result.js CHANGED
@@ -10,7 +10,7 @@ export function jsonResult(value, isError = false) {
10
10
 
11
11
  export function errorResult(system, status, body, contentType, extra = {}) {
12
12
  const parsed = parseAdtError(body, contentType);
13
- return jsonResult(
13
+ const result = jsonResult(
14
14
  {
15
15
  system,
16
16
  status,
@@ -20,4 +20,20 @@ export function errorResult(system, status, body, contentType, extra = {}) {
20
20
  },
21
21
  true
22
22
  );
23
+ // Attach structured metadata for the crash-report wrapper to classify against,
24
+ // without changing the content the client sees. Non-enumerable so it never
25
+ // serializes into the MCP result.
26
+ Object.defineProperty(result, "_adtError", {
27
+ value: {
28
+ system,
29
+ status,
30
+ type: parsed?.type,
31
+ namespace: parsed?.namespace,
32
+ t100: parsed?.properties?.t100,
33
+ message: parsed?.message ?? parsed?.localizedMessage,
34
+ stage: extra.stage,
35
+ },
36
+ enumerable: false,
37
+ });
38
+ return result;
23
39
  }
package/src/server.js CHANGED
@@ -32,6 +32,7 @@ import * as cdsTools from "./tools/cds.js";
32
32
  import * as worklistTools from "./tools/worklist.js";
33
33
  import * as jobTools from "./tools/jobs.js";
34
34
  import * as rapTools from "./tools/rap.js";
35
+ import * as reportTools from "./tools/report.js";
35
36
 
36
37
  const PKG = JSON.parse(
37
38
  readFileSync(
@@ -91,7 +92,7 @@ function getClient(systemName) {
91
92
  return { name, client: clientCache.get(name), profile };
92
93
  }
93
94
 
94
- const ctx = { getClient, config };
95
+ const ctx = { getClient, config, reporter };
95
96
 
96
97
  const TOOL_MODULES = [
97
98
  connectionTools,
@@ -110,6 +111,7 @@ const TOOL_MODULES = [
110
111
  worklistTools,
111
112
  jobTools,
112
113
  rapTools,
114
+ reportTools,
113
115
  ];
114
116
 
115
117
  const tools = [];
@@ -147,7 +149,14 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
147
149
  const handler = handlers[name];
148
150
  if (!handler) return textResult(`Unknown tool: ${name}`, true);
149
151
  try {
150
- return await handler(args);
152
+ const out = await handler(args);
153
+ // A handler that RETURNED a non-2xx ADT result (vs threw) carries structured
154
+ // metadata on _adtError — let the reporter's classifier decide if it's a
155
+ // likely tool defect worth auto-filing. Fire-and-forget; never throws.
156
+ if (out && out._adtError) {
157
+ reporter.reportAdtError({ tool: name, args, ...out._adtError });
158
+ }
159
+ return out;
151
160
  } catch (err) {
152
161
  if (err instanceof ReadOnlyViolationError) {
153
162
  return textResult(
@@ -1,4 +1,4 @@
1
- import { objectUri } from "../object-uris.js";
1
+ import { objectUri, sourceUri, normalizeType } from "../object-uris.js";
2
2
  import { escapeXml } from "../xml.js";
3
3
  import { parseObjectReferences } from "../object-references.js";
4
4
  import { errorResult, jsonResult } from "../result.js";
@@ -131,10 +131,34 @@ async function runAtcWorklist(client, sys, { uris, checkVariant, maxResults }) {
131
131
  };
132
132
  }
133
133
 
134
+ // Normalize a caller-supplied include context into an ADT object URI. Accepts a
135
+ // full ADT path as-is; treats a bare token as a program name.
136
+ export function toContextUri(input) {
137
+ const s = String(input).trim();
138
+ if (s.startsWith("/")) return s;
139
+ return `/sap/bc/adt/programs/programs/${encodeURIComponent(s.toLowerCase())}`;
140
+ }
141
+
142
+ // Best-effort: resolve an include's first main program via its /mainprograms
143
+ // sub-resource. Any failure (older release, no main program, 4xx) returns
144
+ // undefined so the check still runs (and the response carries a hint).
145
+ async function deriveMainProgram(client, includeObjUri) {
146
+ try {
147
+ const res = await client.request({ path: `${includeObjUri}/mainprograms` });
148
+ if (!res.ok) return undefined;
149
+ const text = await res.text();
150
+ const m = text.match(/adtcore:uri="([^"]+)"/i);
151
+ return m ? m[1].replace(/&amp;/g, "&") : undefined;
152
+ } catch {
153
+ return undefined;
154
+ }
155
+ }
156
+
134
157
  export const tools = [
135
158
  {
136
159
  name: "adt_syntax_check",
137
- description: "Run an ADT syntax check on an object. Returns the raw <chkrun:reports> XML.",
160
+ description:
161
+ "Run an ADT syntax check on an object. Returns the raw <chkrun:reports> XML. Includes only compile in the context of a main program: for type=include, pass `context` (the main program / function group), otherwise the check returns status='notProcessed'. When omitted for an include, the tool tries to auto-resolve the include's first main program.",
138
162
  inputSchema: {
139
163
  type: "object",
140
164
  properties: {
@@ -142,6 +166,11 @@ export const tools = [
142
166
  object: { type: "string", description: "Object name." },
143
167
  type: { type: "string", description: OBJECT_TYPE_HINT },
144
168
  group: { type: "string", description: "Function group (for FUGR/FF or FUGR/I)." },
169
+ context: {
170
+ type: "string",
171
+ description:
172
+ "Main-program context for checking an include. Either a full ADT object URI (e.g. '/sap/bc/adt/functions/groups/v61a' or '/sap/bc/adt/programs/programs/zmain') or a bare program name. Only used for includes; auto-resolved from the include's main programs when omitted.",
173
+ },
145
174
  },
146
175
  required: ["object", "type"],
147
176
  },
@@ -258,15 +287,35 @@ export function register({ getClient }) {
258
287
  return {
259
288
  adt_syntax_check: async (args) => {
260
289
  const { client, name: sys } = getClient(args.system);
261
- const objUri = objectUri({
290
+ const t = normalizeType(args.type);
291
+ const isInclude = t === "INCL" || t === "FUGR/I";
292
+
293
+ let checkUri = objectUri({
262
294
  type: args.type,
263
295
  name: args.object,
264
296
  group: args.group,
265
297
  });
298
+ let contextUri;
299
+ if (isInclude) {
300
+ contextUri = args.context
301
+ ? toContextUri(args.context)
302
+ : await deriveMainProgram(client, checkUri);
303
+ // Includes are checked through their source URI with the main program
304
+ // attached as ?context= (the form ADT itself uses in include links).
305
+ const incSource = sourceUri({
306
+ type: args.type,
307
+ name: args.object,
308
+ group: args.group,
309
+ });
310
+ checkUri = contextUri
311
+ ? `${incSource}?context=${encodeURIComponent(contextUri)}`
312
+ : incSource;
313
+ }
314
+
266
315
  const body =
267
316
  `<?xml version="1.0" encoding="UTF-8"?>` +
268
317
  `<chkrun:checkObjectList xmlns:chkrun="http://www.sap.com/adt/checkrun" xmlns:adtcore="http://www.sap.com/adt/core">` +
269
- `<chkrun:checkObject adtcore:uri="${escapeXml(objUri)}"/>` +
318
+ `<chkrun:checkObject adtcore:uri="${escapeXml(checkUri)}"/>` +
270
319
  `</chkrun:checkObjectList>`;
271
320
  const res = await client.request({
272
321
  method: "POST",
@@ -277,7 +326,20 @@ export function register({ getClient }) {
277
326
  });
278
327
  const text = await res.text();
279
328
  if (!res.ok) return errorResult(sys, res.status, text, res.headers.get("content-type"));
280
- return jsonResult({ system: sys, object: args.object, result: text });
329
+ const notProcessed = /chkrun:status="notProcessed"/i.test(text);
330
+ return jsonResult({
331
+ system: sys,
332
+ object: args.object,
333
+ context: contextUri,
334
+ result: text,
335
+ ...(isInclude && notProcessed
336
+ ? {
337
+ hint:
338
+ "Include not processed — no main-program context resolved. Pass `context` " +
339
+ "(the main program / function group ADT URI, e.g. '/sap/bc/adt/programs/programs/zmain').",
340
+ }
341
+ : {}),
342
+ });
281
343
  },
282
344
 
283
345
  adt_run_unit_tests: async (args) => {
@@ -0,0 +1,72 @@
1
+ import { jsonResult } from "../result.js";
2
+
3
+ export const tools = [
4
+ {
5
+ name: "adt_report_issue",
6
+ description:
7
+ "File a bug or enhancement about a sap-adt-mcp TOOL back to the maintainer. The report is redacted (hosts, users, passwords, IPs, emails stripped), de-duplicated, and becomes a GitHub issue. Use this for defects the automatic crash reporter cannot see: wrong data in an otherwise-successful response, a parameter that is silently ignored, or a missing capability. Do NOT use it for problems in the user's ABAP code or SAP system — only for defects in this MCP server's own tools.",
8
+ inputSchema: {
9
+ type: "object",
10
+ properties: {
11
+ tool: {
12
+ type: "string",
13
+ description:
14
+ "The sap-adt-mcp tool the issue is about, e.g. 'adt_read_table'.",
15
+ },
16
+ kind: {
17
+ type: "string",
18
+ enum: ["bug", "enhancement"],
19
+ description:
20
+ "bug = a tool produces wrong/missing output; enhancement = a missing capability. Default bug.",
21
+ },
22
+ summary: {
23
+ type: "string",
24
+ description:
25
+ "One-line summary of the defect. Used in the issue title and the de-duplication key.",
26
+ },
27
+ expected: { type: "string", description: "What you expected to happen." },
28
+ actual: { type: "string", description: "What actually happened." },
29
+ reproArgs: {
30
+ type: "object",
31
+ description:
32
+ "Optional tool arguments that reproduce the issue. Redacted like every report (hosts/users/secrets stripped); suppressed entirely when reporting.includeArgs is off.",
33
+ },
34
+ },
35
+ required: ["tool", "summary"],
36
+ },
37
+ },
38
+ ];
39
+
40
+ export function register({ reporter }) {
41
+ return {
42
+ adt_report_issue: async (args) => {
43
+ if (!reporter) {
44
+ return jsonResult({ ok: false, reason: "reporter unavailable" });
45
+ }
46
+ const r = reporter.reportManual({
47
+ tool: args.tool,
48
+ kind: args.kind,
49
+ summary: args.summary,
50
+ expected: args.expected,
51
+ actual: args.actual,
52
+ reproArgs: args.reproArgs,
53
+ });
54
+ if (!r || !r.ok) {
55
+ return jsonResult({
56
+ ok: false,
57
+ reason: r?.reason ?? "not submitted",
58
+ hint:
59
+ "Reporting may be disabled (reporting.enabled / reporting.allowManual in config, or SAP_ADT_MCP_REPORT=0).",
60
+ });
61
+ }
62
+ return jsonResult({
63
+ ok: true,
64
+ submitted: true,
65
+ kind: r.issueKind,
66
+ fingerprint: r.fingerprint,
67
+ note:
68
+ "Submitted to the maintainer's relay; it will appear as a de-duplicated GitHub issue.",
69
+ });
70
+ },
71
+ };
72
+ }
@@ -3,6 +3,7 @@ import {
3
3
  parseDumpFeed,
4
4
  parseDumpMetadata,
5
5
  parseDumpChapters,
6
+ filterDumpsByUser,
6
7
  CRITICAL_CHAPTER_KEYS,
7
8
  } from "../dump-feed.js";
8
9
  import { SYSTEM_HINT } from "./_shared.js";
@@ -22,7 +23,8 @@ export const tools = [
22
23
  system: { type: "string", description: SYSTEM_HINT },
23
24
  user: {
24
25
  type: "string",
25
- description: "Filter by the user who triggered the dump.",
26
+ description:
27
+ "Filter by the user who triggered the dump (case-insensitive). Enforced client-side — several on-prem releases ignore the server-side user filter.",
26
28
  },
27
29
  host: {
28
30
  type: "string",
@@ -140,12 +142,16 @@ export function register({ getClient }) {
140
142
  });
141
143
  }
142
144
  // Server-side cap is unreliable; some on-prem releases return every
143
- // available entry regardless of maxResults. Trim client-side. Strip
144
- // the per-entry summary too on real systems it's a 10+KB HTML chunk
145
- // (chapter index + back-link) that bloats list responses past the
146
- // tool-output token limit. Agents that need detail call adt_get_dump.
145
+ // available entry regardless of maxResults. The `user` feed filter is
146
+ // likewise ignored on several releases, so enforce it client-side before
147
+ // trimming. Strip the per-entry summary too on real systems it's a
148
+ // 10+KB HTML chunk (chapter index + back-link) that bloats list responses
149
+ // past the tool-output token limit. Agents that need detail call
150
+ // adt_get_dump.
147
151
  const total = entries.length;
148
- const trimmed = entries.slice(0, max).map((e) => {
152
+ const filtered = filterDumpsByUser(entries, args.user);
153
+ const matched = filtered.length;
154
+ const trimmed = filtered.slice(0, max).map((e) => {
149
155
  // eslint-disable-next-line no-unused-vars
150
156
  const { summary, ...rest } = e;
151
157
  return rest;
@@ -154,7 +160,8 @@ export function register({ getClient }) {
154
160
  system: sys,
155
161
  count: trimmed.length,
156
162
  totalReturnedByServer: total,
157
- truncated: total > trimmed.length,
163
+ matchedFilter: args.user ? matched : undefined,
164
+ truncated: matched > trimmed.length,
158
165
  dumps: trimmed,
159
166
  raw: trimmed.length === 0 ? text.slice(0, 4000) : undefined,
160
167
  });
@@ -1,4 +1,4 @@
1
- import { sourceUri, objectUri, normalizeType } from "../object-uris.js";
1
+ import { sourceUri, objectUri, normalizeType, METADATA_XML_ACCEPT } from "../object-uris.js";
2
2
  import { acquireLock, releaseLock } from "../lock.js";
3
3
  import { errorResult, jsonResult } from "../result.js";
4
4
  import { OBJECT_TYPE_HINT, SYSTEM_HINT } from "./_shared.js";
@@ -120,7 +120,7 @@ export const tools = [
120
120
  {
121
121
  name: "adt_get_source",
122
122
  description:
123
- "Fetch the ABAP source of an object (program, class, interface, function module, include, CDS, table). Returns plain text. For sources larger than the MCP per-call output cap (~64 KB), use firstLine/lastLine to paginate or onlyMethod to slice a single method body.",
123
+ "Fetch the ABAP source of an object (program, class, interface, function module, include, CDS, table). Returns plain text. DDIC primitives without plain-text source (data element, domain, message class) return their ADT XML metadata instead (format: 'xml'). For sources larger than the MCP per-call output cap (~64 KB), use firstLine/lastLine to paginate or onlyMethod to slice a single method body.",
124
124
  inputSchema: {
125
125
  type: "object",
126
126
  properties: {
@@ -273,12 +273,35 @@ export function register({ getClient }) {
273
273
  return {
274
274
  adt_get_source: async (args) => {
275
275
  const { client, name: sys } = getClient(args.system);
276
+ const t = normalizeType(args.type);
276
277
  const path = sourceUri({
277
278
  type: args.type,
278
279
  name: args.object,
279
280
  group: args.group,
280
281
  include: args.include,
281
282
  });
283
+
284
+ // DDIC primitives (data element / domain / message class) have no
285
+ // plain-text source — fetch their XML metadata with the right media type
286
+ // (text/plain would 406) and return it as-is.
287
+ const metaAccept = METADATA_XML_ACCEPT[t];
288
+ if (metaAccept) {
289
+ const res = await client.request({ path, accept: metaAccept });
290
+ const text = await res.text();
291
+ if (!res.ok) return errorResult(sys, res.status, text, res.headers.get("content-type"));
292
+ return jsonResult({
293
+ system: sys,
294
+ object: args.object,
295
+ type: t,
296
+ path,
297
+ format: "xml",
298
+ source: text,
299
+ bytes: text.length,
300
+ totalLines: text.split(/\r?\n/).length,
301
+ note: `${t} has no plain-text source; returning ADT XML metadata (${metaAccept.split("+")[0]}+xml).`,
302
+ });
303
+ }
304
+
282
305
  const res = await client.request({ path, accept: "text/plain" });
283
306
  const text = await res.text();
284
307
  if (!res.ok) return errorResult(sys, res.status, text, res.headers.get("content-type"));